Java & Spring Boot - Introduzione

Java & Spring Boot - Introduzione

Articolo

Indice

Fondamenti Java

Collections e API Stream

Gestione degli Errori

Framework Spring Boot


Fondamenti di Java

La storia di Java

Nei primi anni ‘90, il panorama informatico era dominato da linguaggi come C++, potenti ma complessi e legati alla piattaforma hardware su cui venivano compilati. L’emergere di dispositivi elettronici di consumo (come set-top box e i primi palmari) e la nascente diffusione di Internet crearono la necessità di un linguaggio di programmazione che fosse più semplice, robusto e, soprattutto, indipendente dalla piattaforma.

In questo contesto, nel 1991, un team di ingegneri di Sun Microsystems guidato da James Gosling avviò il “Green Project”. L’obiettivo iniziale era creare un linguaggio per dispositivi intelligenti. Dopo varie iterazioni, nel 1995 venne rilasciato Java, con il celebre motto “Write Once, Run Anywhere” (Scrivi una volta, esegui ovunque). Questa filosofia si rivelò perfetta per il boom di Internet, dove le applicazioni dovevano funzionare su una vasta gamma di sistemi operativi e architetture hardware.

Filosofia e Principi di Design:

La filosofia di Java si basa su alcuni principi chiave che ne hanno decretato il successo:

  • Semplice e Familiare: La sintassi di Java fu volutamente mantenuta simile a quella di C++, per facilitarne l’adozione da parte degli sviluppatori già esperti, ma eliminando le caratteristiche più complesse e soggette a errori, come la gestione manuale della memoria e l’ereditarietà multipla.
  • Orientato agli Oggetti (Object-Oriented): Java è stato progettato da zero come un linguaggio orientato agli oggetti. Questo paradigma, che modella il software come un insieme di oggetti interagenti, favorisce la modularità, il riutilizzo del codice e la manutenibilità.
  • Indipendente dalla Piattaforma: Questo è il principio cardine. Il codice Java non viene compilato in codice macchina nativo, ma in un formato intermedio chiamato “bytecode”. Questo bytecode può essere eseguito su qualsiasi dispositivo dotato di una Java Virtual Machine (JVM).
  • Robusto e Sicuro: Java include meccanismi per la gestione automatica della memoria (Garbage Collection) che prevengono errori comuni come i memory leak. Inoltre, il modello di sicurezza della JVM limita l’accesso delle applicazioni alle risorse del sistema, rendendolo una scelta sicura per le applicazioni di rete.
  • Alte Prestazioni: Sebbene inizialmente criticato per le prestazioni inferiori rispetto ai linguaggi compilati nativamente, l’introduzione di compilatori Just-In-Time (JIT) nella JVM ha permesso a Java di raggiungere prestazioni competitive.
  • Multithreaded: Java ha un supporto nativo per il multithreading, consentendo di scrivere programmi in grado di eseguire più compiti contemporaneamente, una caratteristica fondamentale per le applicazioni interattive e di rete.

Il cuore di Java, la JVM

Il Processo di Compilazione ed Esecuzione:

  1. Scrittura del Codice Sorgente: Lo sviluppatore scrive il codice in file con estensione .java.
  2. Compilazione in Bytecode: Il compilatore Java (javac) traduce il codice sorgente in bytecode, un insieme di istruzioni indipendenti dalla piattaforma, e lo salva in file con estensione .class. Durante questa fase, il compilatore esegue anche un’analisi sintattica e semantica del codice per rilevare errori.
  3. Esecuzione sulla JVM: La Java Virtual Machine (JVM) carica i file .class, li verifica per garantirne la sicurezza e l’integrità, e infine li esegue.

Architettura della JVM:

La JVM è un’astrazione di una macchina fisica ed è composta da tre componenti principali:

  1. Class Loader Subsystem: Si occupa di caricare, collegare e inizializzare le classi.

    • Loading: Legge i file .class e carica i dati binari nell’area di memoria della JVM.
    • Linking: Esegue la verifica del bytecode, prepara la memoria per le variabili statiche e risolve i riferimenti simbolici.
    • Initialization: Esegue i blocchi di inizializzazione statica delle classi.
  2. Runtime Data Areas: Sono le aree di memoria che la JVM utilizza durante l’esecuzione del programma.

    • Method Area: Condivisa tra tutti i thread, memorizza le informazioni a livello di classe come il bytecode, i metadati e il constant pool.
    • Heap: Anche questa è un’area di memoria condivisa dove vengono allocati tutti gli oggetti e gli array creati durante l’esecuzione del programma. È gestita dal Garbage Collector.
    • Stack Area: Ogni thread ha il proprio stack privato. Lo stack memorizza i “frame”, ognuno dei quali contiene le variabili locali e i risultati parziali di un metodo.
    • PC Registers: Ogni thread ha il proprio PC (Program Counter) register che tiene traccia dell’istruzione della JVM attualmente in esecuzione.
    • Native Method Stacks: Contiene informazioni sui metodi nativi (scritti in altri linguaggi come C/C++).
  3. Execution Engine: È il cuore della JVM, responsabile dell’esecuzione del bytecode.

    • Interpreter: Legge, interpreta ed esegue le istruzioni del bytecode una per una.
    • Just-In-Time (JIT) Compiler: Per migliorare le prestazioni, il JIT compiler analizza il bytecode durante l’esecuzione e compila le parti di codice più utilizzate (“hotspots”) in codice macchina nativo, che viene poi eseguito direttamente dalla CPU.
    • Garbage Collector (GC): È un processo in background che gestisce automaticamente la memoria nell’heap. Identifica e rimuove gli oggetti che non sono più referenziati da nessuna parte del programma, liberando così la memoria.

Esempio Pratico - “Hello, World!”

Obiettivo: Visualizzare il processo di compilazione ed esecuzione con il classico programma “Hello, World!”.

Codice Sorgente (HelloWorld.java):

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, World!");
    }
}

Passaggi:

  1. Compilazione:

    • Aprire un terminale o prompt dei comandi.
    • Navigare nella directory dove è stato salvato il file HelloWorld.java.
    • Eseguire il comando: javac HelloWorld.java
    • Risultato: Verrà creato un nuovo file nella stessa directory: HelloWorld.class. Questo file contiene il bytecode.
  2. Esecuzione:

    • Nello stesso terminale, eseguire il comando: java HelloWorld (notare l’assenza dell’estensione .class).
    • Spiegazione:
      • Il comando java avvia la JVM.
      • La JVM, tramite il suo Class Loader, cerca e carica il file HelloWorld.class.
      • L’Execution Engine cerca il metodo public static void main(String[] args).
      • L’interprete (o il JIT compiler) esegue il bytecode del metodo main, che istruisce il sistema a stampare la stringa “Hello, World!” sulla console.

Esercizio:

  1. Modificare il file HelloWorld.java per stampare un messaggio diverso.
  2. Ricompilare il file.
  3. Eseguire nuovamente il programma e verificare che l’output sia cambiato.
  4. Provare a eseguire il programma senza ricompilarlo dopo una modifica. Cosa succede? Perché?
  5. Introdurre un errore di sintassi nel file .java (es. omettendo un punto e virgola) e provare a compilarlo. Osservare l’errore restituito dal compilatore javac.

I tipi di dato in Java

Java ha un sistema di tipi statico e fortemente tipizzato.

  • Statico: Il tipo di ogni variabile e di ogni espressione è noto a tempo di compilazione. Questo permette al compilatore di rilevare molti errori comuni prima ancora che il programma venga eseguito.
  • Fortemente Tipizzato: Le operazioni sono consentite solo su tipi di dati compatibili. Non sono permesse conversioni implicite che potrebbero portare a una perdita di dati (ad esempio, da un double a un int) senza un “cast” esplicito da parte del programmatore.

In Java, i tipi di dati si dividono in due categorie principali:

  1. Tipi Primitivi:

    • Sono i mattoni fondamentali del linguaggio e non sono oggetti.
    • Memorizzano direttamente il loro valore nella memoria stack (per le variabili locali) o nell’heap (se sono campi di un oggetto).
    • Sono più efficienti in termini di memoria e velocità di accesso.
    • Ce ne sono 8: byte, short, int, long, float, double, char, boolean.
    • Sono immutabili.
  2. Tipi Riferimento (Reference Types):

    • Qualsiasi istanza di una classe, un’interfaccia, un’enumerazione o un array è un tipo di riferimento.
    • Una variabile di tipo riferimento non contiene l’oggetto stesso, ma l’indirizzo di memoria (un riferimento) alla posizione nell’heap dove l’oggetto è memorizzato.
    • Il loro valore di default è null.
    • Permettono di modellare entità complesse e di utilizzare i principi dell’OOP.

Attenzione!!!

In Java, gli argomenti ai metodi vengono sempre passati per valore MA:

  • Per i tipi primitivi, viene copiato il valore stesso. Qualsiasi modifica al parametro all’interno del metodo non influisce sulla variabile originale.
  • Per i tipi riferimento, viene copiato il valore del riferimento (l’indirizzo di memoria). Questo significa che il metodo riceve una copia del riferimento che punta allo stesso oggetto nell’heap. Pertanto, se il metodo modifica lo stato dell’oggetto, la modifica sarà visibile anche all’esterno del metodo.

Esempio - Tipi Primitivi vs. Riferimento

Obiettivo: Dimostrare la differenza nel comportamento tra tipi primitivi e di riferimento quando passati a un metodo.

Codice di Esempio:

class Wallet {
    private int balance;

    public Wallet(int balance) {
        this.balance = balance;
    }

    public void addMoney(int amount) {
        this.balance += amount;
    }

    public int getBalance() {
        return balance;
    }
}

public class TypeExample {
    public static void main(String[] args) {
        // Test con tipo primitivo
        int myCash = 50;
        System.out.println("Valore iniziale del contante: " + myCash);
        tryToModify(myCash);
        System.out.println("Valore finale del contante: " + myCash);

        System.out.println("--------------------");

        // Test con tipo riferimento
        Wallet myWallet = new Wallet(200);
        System.out.println("Saldo iniziale del portafoglio: " + myWallet.getBalance());
        tryToModify(myWallet);
        System.out.println("Saldo finale del portafoglio: " + myWallet.getBalance());
    }

    public static void tryToModify(int cash) {
        cash = 1000; // Modifica la copia locale
    }

    public static void tryToModify(Wallet wallet) {
        wallet.addMoney(100); // Modifica lo stato dell'oggetto originale
    }
}

Output Atteso:

Valore iniziale del contante: 50
Valore finale del contante: 50
--------------------
Saldo iniziale del portafoglio: 200
Saldo finale del portafoglio: 300

Analisi:

  • Nel primo caso (myCash), il metodo tryToModify riceve una copia del valore 50. La modifica cash = 1000 avviene solo su questa copia locale. La variabile originale myCash nel main non viene toccata.
  • Nel secondo caso (myWallet), il metodo tryToModify riceve una copia del riferimento all’oggetto Wallet. Entrambi i riferimenti (quello nel main e quello nel metodo) puntano allo stesso oggetto in memoria. Quindi, quando il metodo invoca wallet.addMoney(100), sta modificando lo stato dell’unico oggetto esistente, e la modifica è visibile anche dal main.

Esercizi Tipi Primitivi vs. Tipi Riferimento

Difficoltà crescente: dalla sintassi base alla comprensione profonda della memoria e degli oggetti immutabili.

  1. Esercizio 1 (Base): Dichiarazione e Assegnazione
    • Scrivi un programma che dichiari e inizializzi una variabile primitiva int chiamata eta e una variabile di riferimento String chiamata nome. Stampale a video.
  2. Esercizio 2 (Facile): Passaggio per Valore con Primitivi
    • Crea un metodo incrementa(int numero) che prende un intero, gli aggiunge 10 e lo stampa. Nel main, dichiara un intero, passalo a questo metodo e poi stampalo di nuovo nel main. Osserva e spiega perché il valore originale non è cambiato.
  3. Esercizio 3 (Medio-Facile): Modifica dello Stato di un Oggetto
    • Usa la classe StringBuilder. Crea un metodo aggiungiTesto(StringBuilder builder) che appende la stringa ” Mondo!” al StringBuilder passato come parametro. Nel main, crea uno StringBuilder con “Ciao”, passalo al metodo e poi stampalo di nuovo. Osserva e spiega perché questa volta la modifica è visibile.
  4. Esercizio 4 (Medio): Riassegnazione del Riferimento
    • Crea una classe semplice Punto con due attributi int x, y. Scrivi un metodo riassegna(Punto p) che crea un nuovo punto (p = new Punto(100, 100);). Nel main, crea un Punto, stampalo, passalo al metodo riassegna e stampalo di nuovo. Spiega perché l’oggetto originale nel main non è cambiato.
  5. Esercizio 5 (Medio-Difficile): Manipolazione di Array
    • Crea un metodo modificaArray(int[] array) che imposta il primo elemento dell’array a 99. Crea un secondo metodo riassegnaArray(int[] array) che crea un nuovo array (array = new int[]{0, 0, 0};). Nel main, crea un array {1, 2, 3}, passalo prima a modificaArray e stampalo, poi passalo a riassegnaArray e stampalo di nuovo. Spiega i due risultati diversi.
  6. Esercizio 6 (Difficile): Wrapper Classes e Immutabilità
    • Crea un metodo modificaWrapper(Integer numero) che cerca di cambiare il valore del wrapper (numero = 20;). Nel main, crea una variabile Integer originale = 10;, passala al metodo e stampala di nuovo. Spiega il risultato alla luce del fatto che le classi Wrapper (Integer, Double, etc.) sono immutabili. Confronta questo comportamento con quello di StringBuilder (esercizio 3).

Storia dell’OOP, metafore mondo reale, differenze paradigma procedurale

Breve Storia dell’OOP (Object-Oriented Programming):

Le radici della programmazione orientata agli oggetti risalgono agli anni ‘60 con il linguaggio Simula, creato per realizzare simulazioni del mondo reale. L’idea era di raggruppare dati e le operazioni su di essi in singole unità chiamate “oggetti”.

Negli anni ‘70, il concetto fu pienamente sviluppato presso Xerox PARC con la creazione di Smalltalk, che introdusse termini e concetti chiave come l’ereditarietà e il polimorfismo.

La popolarità dell’OOP è esplosa negli anni ‘80 con C++, che ha aggiunto le caratteristiche orientate agli oggetti al linguaggio C, e poi negli anni ‘90 con l’arrivo di Java, che è stato progettato fin dall’inizio come un linguaggio strettamente legato alla OOP.

Metafore del Mondo Reale:

L’OOP si basa sull’idea di modellare il software in modo che rifletta le entità del mondo reale.

  • Classe: È un progetto o un modello. Ad esempio, la classe Automobile descrive le caratteristiche (attributi come colore, marca, velocitàMassima) e i comportamenti (metodi come accelera(), frena(), accendi()) che tutte le automobili hanno in comune.
  • Oggetto: È un’istanza concreta di una classe. Se Automobile è il progetto, la “mia Fiat Panda rossa” è un oggetto, con i suoi valori specifici per gli attributi (colore = "rosso", marca = "Fiat").
  • Messaggi: Gli oggetti interagiscono tra loro inviandosi messaggi, che in pratica corrispondono a chiamate di metodi. Ad esempio, un oggetto Guidatore può inviare il messaggio accelera() all’oggetto Automobile.

Differenze con il Paradigma Procedurale:

Il paradigma procedurale, dominante prima dell’OOP (es. C, Pascal, FORTRAN), si concentra su una sequenza di istruzioni (procedure o funzioni) che operano su dati.

AspettoProgrammazione ProceduraleProgrammazione Orientata agli Oggetti
Focus PrincipaleSulle procedure e algoritmi. Il programma è una sequenza di passi.Sugli oggetti e sui dati. Il programma è un’interazione tra oggetti.
OrganizzazioneDiviso in funzioni.Diviso in classi e oggetti.
Dati vs. FunzioniI dati e le funzioni che operano su di essi sono separati. I dati sono spesso globali e possono muoversi liberamente tra le funzioni.I dati (attributi) e le funzioni (metodi) sono strettamente legati all’interno degli oggetti (incapsulamento).
ApproccioTop-down: si parte dal problema principale e lo si scompone in sotto-problemi (funzioni).Bottom-up: si modellano prima gli oggetti di base e poi si compongono per creare sistemi complessi.
Sicurezza DatiMeno sicura. I dati globali possono essere modificati da qualsiasi funzione.Più sicura grazie all’information hiding. Lo stato interno di un oggetto è protetto.
Riutilizzo CodiceLimitato alle funzioni.Elevato grazie all’ereditarietà e alla composizione.
ComplessitàAdatta a problemi di media complessità. Diventa difficile da gestire per sistemi grandi.Molto adatta a modellare e gestire sistemi complessi del mondo reale.

Incapsulamento, information hiding e conseguenze della violazione

L’Incapsulamento:

L’incapsulamento è uno dei quattro pilastri fondamentali dell’OOP. La sua filosofia si basa su due concetti correlati:

  1. Bundling (Raggruppamento): Consiste nel raggruppare i dati (attributi) e i metodi che operano su quei dati all’interno di una singola unità, la classe. Si può pensare a una capsula medicinale che tiene insieme tutti i suoi componenti.
  2. Information Hiding (Nascondere le Informazioni): Questo è il vero potere dell’incapsulamento. Consiste nel nascondere i dettagli implementativi interni di una classe al mondo esterno. L’accesso allo stato interno dell’oggetto (i suoi attributi) è controllato e avviene solo attraverso un’interfaccia pubblica ben definita (i metodi pubblici).

Come si implementa in Java?

  • Si dichiarano gli attributi (variabili d’istanza) come private. Questo impedisce l’accesso diretto dall’esterno della classe.
  • Si forniscono metodi pubblici (spesso chiamati getter e setter) per leggere e modificare gli attributi in modo controllato.

Metafora del Mondo Reale:

Pensa a un’automobile. Tu, come guidatore, interagisci con un’interfaccia pubblica: volante, pedali, cambio. Non hai bisogno di conoscere (e non dovresti poter modificare direttamente) il funzionamento interno del motore, l’iniezione del carburante o l’elettronica. L’interfaccia pubblica nasconde la complessità interna.

Conseguenze della Violazione dell’Incapsulamento:

Se gli attributi di una classe fossero pubblici, qualsiasi altra parte del codice potrebbe modificarli direttamente, portando a gravi problemi:

  • Stato Inconsistente: Un oggetto potrebbe finire in uno stato invalido. Ad esempio, se un conto bancario avesse un attributo public double balance, un altro pezzo di codice potrebbe impostarlo a un valore negativo, cosa che il metodo withdraw() (preleva) avrebbe impedito.
  • Accoppiamento Elevato (High Coupling): Se molte parti del codice dipendono dai dettagli interni di una classe, diventa quasi impossibile modificare quella classe senza “rompere” tutto il resto. Ogni modifica interna si ripercuote a cascata su tutto il sistema.
  • Manutenzione Difficile: Il codice diventa fragile e difficile da capire. Non è più chiaro chi è responsabile della modifica di un certo dato.
  • Perdita di Flessibilità: Non è più possibile cambiare l’implementazione interna di una classe (ad esempio, cambiare il modo in cui un dato viene memorizzato) senza costringere tutti i “clienti” di quella classe a essere riscritti.

L’incapsulamento garantisce che una classe sia l’unica responsabile della coerenza del proprio stato interno, promuovendo un design robusto, flessibile e manutenibile.

Esercizi Incapsulamento

Difficoltà crescente: dall’implementazione base alla gestione di oggetti mutabili interni per un incapsulamento a prova di errore.

  1. Esercizio 1 (Base): La Classe “Anemica”
    • Crea una classe ContoCorrente con due attributi public String titolare; e public double saldo;. Nel main, crea un’istanza e imposta un saldo negativo direttamente (conto.saldo = -500;). Spiega perché questo è un problema.
  2. Esercizio 2 (Facile): Getters e Setters
    • Modifica la classe ContoCorrente dell’esercizio 1. Rendi gli attributi private e aggiungi metodi public getTitolare(), setTitolare(), getSaldo() e setSaldo().
  3. Esercizio 3 (Medio-Facile): Logica di Validazione
    • Migliora l’esercizio 2. All’interno del metodo setSaldo(double nuovoSaldo), aggiungi un controllo per assicurarti che nuovoSaldo non sia negativo. Se lo è, stampa un messaggio di errore e non modificare il saldo. Fai lo stesso per i metodi deposita(double importo) e preleva(double importo).
  4. Esercizio 4 (Medio): Campi Read-Only
    • Aggiungi alla classe ContoCorrente un attributo private final String IBAN. Rimuovi il setter per l’IBAN e inizializzalo solo tramite il costruttore. Dimostra che una volta creato l’oggetto, il suo IBAN non può più essere modificato.
  5. Esercizio 5 (Medio-Difficile): Incapsulamento di Collezioni
    • Aggiungi un attributo private List<String> listaMovimenti alla classe ContoCorrente. Nel metodo getListaMovimenti(), se restituisci direttamente la lista (return this.listaMovimenti;), il chiamante può modificarla dall’esterno (conto.getListaMovimenti().clear();), rompendo l’incapsulamento. Modifica il getter per restituire una copia difensiva (new ArrayList<>(this.listaMovimenti)) o una vista non modificabile (Collections.unmodifiableList(this.listaMovimenti)).
  6. Esercizio 6 (Difficile): Incapsulamento di Oggetti Interni Mutabili
    • Aggiungi un attributo private Date dataApertura (usa java.util.Date che è mutabile). Se il metodo getDataApertura() restituisce this.dataApertura, il codice esterno può fare conto.getDataApertura().setTime(0); e modificare lo stato interno del tuo oggetto. Risolvi il problema restituendo una copia dell’oggetto Date sia nel getter (return new Date(this.dataApertura.getTime());) sia ricevendo una copia nel costruttore/setter.

Ereditarietà dal mondo biologico, meccanismo interno, trade-offs

Metafora dal Mondo Biologico:

L’ereditarietà nella programmazione orientata agli oggetti (OOP) è un concetto direttamente ispirato all’ereditarietà biologica. Proprio come un figlio eredita caratteristiche dai genitori (colore degli occhi, altezza), in Java una classe (detta sottoclasse o classe figlia) può ereditare attributi e metodi da un’altra classe (detta superclasse o classe madre).

Questo permette di creare una gerarchia di classi, stabilendo una relazione “è un” (IS-A). Ad esempio, un Cane è un Animale. Un Gatto è un Animale. Sia Cane che Gatto erediteranno le caratteristiche comuni di Animale (come nome, età, e il metodo mangia()), ma potranno anche avere comportamenti e attributi specifici (es. il Cane ha il metodo abbaia(), il Gatto ha miagola()).

Meccanismo Interno in Java:

Parola Chiave extends: L’ereditarietà si implementa usando la parola chiave extends.

    class Animale {
        String nome;
        public void mangia() {
            System.out.println("Questo animale mangia.");
        }
    }

    class Cane extends Animale {
        public void abbaia() {
            System.out.println("Woof!");
        }
    }

Cosa viene ereditato: La sottoclasse eredita tutti i membri public e protected della superclasse. I membri private non sono direttamente accessibili, ma fanno parte dello stato dell’oggetto. I costruttori non vengono ereditati, ma il costruttore della sottoclasse deve chiamare (implicitamente o esplicitamente con super()) un costruttore della superclasse.

Ereditarietà Singola: Java supporta solo l’ereditarietà singola, il che significa che una classe può estendere al massimo una sola altra classe. Questo previene i problemi di ambiguità del “diamond problem” presenti in linguaggi con ereditarietà multipla come C++.

Trade-offs (Vantaggi e Svantaggi):

Vantaggi:

  1. Riutilizzo del Codice: È il vantaggio più evidente. Il codice comune viene scritto una sola volta nella superclasse e riutilizzato da tutte le sottoclassi.
  2. Organizzazione Logica: Crea gerarchie chiare e comprensibili che modellano le relazioni del dominio del problema.
  3. Polimorfismo: L’ereditarietà è il prerequisito per il polimorfismo (trattato nella slide 18), che permette di trattare oggetti di sottoclassi diverse in modo uniforme attraverso il riferimento alla superclasse.

Svantaggi e Rischi:

  1. Accoppiamento Forte (Tight Coupling): La sottoclasse è strettamente legata all’implementazione della superclasse. Una modifica nella superclasse può avere effetti inaspettati e “rompere” il funzionamento delle sottoclassi.
  2. Gerarchie Fragili (Fragile Base Class Problem): Se una superclasse viene modificata, tutte le sottoclassi potrebbero dover essere ricompilate e ritestate, anche se non usano direttamente la parte modificata.
  3. Rompere l’Incapsulamento: L’ereditarietà può indebolire l’incapsulamento. Se la sottoclasse dipende da dettagli implementativi della superclasse (e non solo dalla sua interfaccia pubblica), l’information hiding viene compromesso.
  4. Abuso della Relazione “IS-A”: A volte gli sviluppatori usano l’ereditarietà solo per riutilizzare il codice, anche quando la relazione “è un” non ha senso. Questo porta a gerarchie illogiche e difficili da mantenere.

Alternativa: Spesso, la composizione è preferibile all’ereditarietà (“Composition over Inheritance”). Invece di dire “A è un B”, si dice “A ha un B”. Questo crea un accoppiamento più debole e un design più flessibile.

Esercizi Ereditarietà

Difficoltà crescente: dalla creazione di una semplice gerarchia alla comprensione dei modificatori di visibilità e delle parole chiave final.

  1. Esercizio 1 (Base): Superclasse e Sottoclasse
    • Crea una classe Personaggio con attributi nome e puntiVita. Crea una sottoclasse Guerriero che extends Personaggio e aggiunge un attributo arma. Nel main, crea un Guerriero e accedi sia agli attributi ereditati che a quello specifico.
  2. Esercizio 2 (Facile): Costruttori e super()
    • Aggiungi un costruttore alla classe Personaggio che inizializza nome e puntiVita. Il compilatore ora segnalerà un errore in Guerriero. Correggilo creando un costruttore in Guerriero che accetta nome, puntiVita e arma, e che usa super(nome, puntiVita); per chiamare il costruttore della superclasse.
  3. Esercizio 3 (Medio-Facile): Override di Metodi
    • Aggiungi un metodo descrivi() a Personaggio che stampa le informazioni base. Fai l’override (@Override) del metodo descrivi() in Guerriero in modo che stampi anche l’arma. Per non riscrivere codice, la versione del Guerriero deve prima chiamare super.descrivi();.
  4. Esercizio 4 (Medio): Modificatore protected
    • Cambia la visibilità di puntiVita in Personaggio da private a protected. Crea un metodo subisciDanno(int danno) in Guerriero che modifica direttamente this.puntiVita. Dimostra che funziona. Ora prova ad accedere a puntiVita da una classe non correlata (es. dal main) e osserva l’errore.
  5. Esercizio 5 (Medio-Difficile): Gerarchia a Tre Livelli
    • Crea una nuova classe Paladino che extends Guerriero. Aggiungi un attributo fede. Fai l’override del metodo descrivi() anche in Paladino, assicurandoti che chiami super.descrivi() per riutilizzare la logica di Guerriero (che a sua volta riutilizza quella di Personaggio).
  6. Esercizio 6 (Difficile): Classi e Metodi final
    • Crea una classe Ladro. Rendi final il suo metodo scassina(). Prova a creare una sottoclasse Assassino che fa l’override di scassina() e osserva l’errore del compilatore. Successivamente, rendi l’intera classe Ladro final. Prova a far estendere Assassino da Ladro e osserva l’errore.

Polimorfismo dettagliato, Virtual Method Table, binding dinamico

Cos’è il Polimorfismo?

Il polimorfismo (dal greco “molte forme”) è la capacità di un oggetto di assumere più forme. In OOP, significa che un riferimento a una superclasse può puntare a un oggetto di una qualsiasi delle sue sottoclassi. Questo permette di scrivere codice generico che opera su oggetti della superclasse, ma che al momento dell’esecuzione si comporterà in modo specifico a seconda del tipo reale dell’oggetto.

Esempio:

Animale mioAnimale;

mioAnimale = new Cane(); // Un Cane è un Animale
mioAnimale.emettiSuono(); // Chiama il metodo specifico del Cane

mioAnimale = new Gatto(); // Un Gatto è un Animale
mioAnimale.emettiSuono(); // Chiama il metodo specifico del Gatto

Nell’esempio sopra, la stessa riga di codice mioAnimale.emettiSuono() produce comportamenti diversi a seconda dell’oggetto a cui mioAnimale sta puntando in quel momento.

Meccanismo Interno: Binding Dinamico e Virtual Method Table (VMT)

Come fa la JVM a sapere quale metodo emettiSuono() chiamare al momento dell’esecuzione? La risposta sta nel binding dinamico (o late binding), reso possibile da un meccanismo interno chiamato Virtual Method Table (VMT) o vtable.

Binding Statico vs. Dinamico:

  • Binding Statico (o Early Binding): La decisione su quale metodo eseguire viene presa a tempo di compilazione. Questo avviene per i metodi static, private e final, poiché il compilatore sa esattamente quale implementazione deve essere chiamata.
  • Binding Dinamico (o Late Binding): La decisione viene presa a tempo di esecuzione (runtime). Questo si applica a tutti gli altri metodi di istanza (virtual methods). La JVM deve determinare il tipo reale dell’oggetto e chiamare l’implementazione del metodo appropriata.

Virtual Method Table (VMT):

  • Quando la JVM carica una classe, crea una VMT per quella classe.
  • La VMT è essenzialmente un array di puntatori a metodi. Ogni oggetto creato da quella classe contiene un puntatore nascosto alla VMT della sua classe.
  • Quando una sottoclasse eredita da una superclasse, la sua VMT inizia come una copia della VMT della superclasse.
  • Se la sottoclasse fa l’override di un metodo, l’indirizzo corrispondente nella sua VMT viene aggiornato per puntare alla nuova implementazione. Se non fa l’override, il puntatore rimane quello del metodo della superclasse.

Come Funziona la Chiamata a Metodo

Quando viene eseguito mioAnimale.emettiSuono():

  • La JVM segue il riferimento mioAnimale per trovare l’oggetto nell’heap.
  • Dall’oggetto, segue il puntatore nascosto per accedere alla VMT della classe reale dell’oggetto (che potrebbe essere Cane o Gatto).
  • La JVM cerca l’indirizzo del metodo emettiSuono() all’interno della VMT. Questo indirizzo sarà quello del metodo specifico della classe Cane o Gatto.
  • Viene eseguito il codice a quell’indirizzo.

Questo processo, sebbene sembri complesso, è estremamente efficiente e costituisce il cuore del polimorfismo in Java, permettendo di scrivere codice flessibile, estensibile e manutenibile.

Esercizi Polimorfismo

Difficoltà crescente: dal concetto base all’uso di instanceof e del downcasting per accedere a funzionalità specifiche delle sottoclassi.

  1. Esercizio 1 (Base): Array Polimorfico
    • Crea una classe astratta StrumentoMusicale con un metodo astratto suona(). Crea due sottoclassi concrete Chitarra e Pianoforte che implementano suona(). Nel main, crea un array di tipo StrumentoMusicale[] e inserisci al suo interno un’istanza di Chitarra e una di Pianoforte.
  2. Esercizio 2 (Facile): Chiamate Polimorfiche
    • Usando l’array dell’esercizio 1, scrivi un ciclo for-each che itera su ogni StrumentoMusicale e chiama il metodo suona(). Osserva come viene eseguito il metodo corretto per ogni oggetto.
  3. Esercizio 3 (Medio-Facile): Polimorfismo nei Parametri
    • Crea un metodo statico accordaStrumento(StrumentoMusicale strumento) che stampa “Accordando lo strumento…” e poi chiama strumento.suona(). Nel main, passa a questo metodo sia l’oggetto Chitarra che l’oggetto Pianoforte e verifica il funzionamento.
  4. Esercizio 4 (Medio): instanceof e Downcasting
    • Aggiungi un metodo specifico solo alla classe Chitarra chiamato cambiaCorde(). Nel ciclo for-each dell’esercizio 2, aggiungi un controllo: if (strumento instanceof Chitarra), e se è vero, esegui un downcasting (Chitarra chitarra = (Chitarra) strumento;) e chiama il metodo chitarra.cambiaCorde().
  5. Esercizio 5 (Medio-Difficile): Polimorfismo con Interfacce
    • Crea un’interfaccia Elettrico con un metodo collegaAllaCorrente(). Fai in modo che la classe Chitarra implementi questa interfaccia, ma Pianoforte no. Nel ciclo, aggiungi un controllo if (strumento instanceof Elettrico) e, in caso affermativo, esegui il cast all’interfaccia e chiama il metodo specifico.
  6. Esercizio 6 (Difficile): Evitare if-else con instanceof (Visitor Pattern Semplificato)
    • Questo esercizio introduce un’alternativa più elegante al downcasting. Aggiungi un metodo accetta(Visitor v) a StrumentoMusicale. Crea un’interfaccia Visitor con metodi visit(Chitarra c) e visit(Pianoforte p). In Chitarra, l’implementazione di accetta sarà v.visit(this);. Ora, invece di if-else, puoi creare una classe ManutentoreVisitor che implementa Visitor e contiene la logica specifica per ogni strumento. Il ciclo nel main diventerà semplicemente strumento.accetta(mioManutentore);.

Interfacce, vantaggi architetturali e design patterns

Le Interfacce come Contratti:

Un’interfaccia in Java è un tipo di riferimento completamente astratto. È una raccolta di firme di metodi (e costanti statiche) senza alcuna implementazione. Quando una classe implements (implementa) un’interfaccia, essa stipula un contratto.

Il Contratto stabilisce che:

  • La classe promette di fornire un’implementazione concreta per tutti i metodi definiti nell’interfaccia.
  • Il compilatore Java agisce come un notaio, verificando che la classe rispetti il contratto. Se anche solo un metodo dell’interfaccia non viene implementato, il codice non compila.

Questo meccanismo separa la definizione del comportamento (cosa un oggetto deve fare) dalla sua implementazione (come lo fa).

// IL CONTRATTO
interface Volante {
    void decolla();
    void atterra();
}

// Chi firma il contratto, DEVE rispettarlo
class Aereo implements Volante {
    @Override
    public void decolla() { /* implementazione per l'aereo */ }
    @Override
    public void atterra() { /* implementazione per l'aereo */ }
}

class Uccello implements Volante {
    @Override
    public void decolla() { /* implementazione per l'uccello */ }
    @Override
    public void atterra() { /* implementazione per l'uccello */ }
}

Vantaggi Architetturali

Disaccoppiamento (Loose Coupling): Il codice client può dipendere dall’interfaccia (l’astrazione) invece che dalle implementazioni concrete. Questo significa che possiamo cambiare o aggiungere nuove implementazioni dell’interfaccia senza modificare il codice client.

public class TorreDiControllo {
    public void autorizzaDecollo(Volante v) {
        v.decolla(); // Non mi importa se è un Aereo o un Uccello, so solo che può decollare
    }
}

Polimorfismo: Le interfacce permettono di ottenere il polimorfismo anche tra classi che non hanno una relazione di ereditarietà diretta. Un Aereo e un Uccello non hanno una superclasse comune (oltre Object), ma possono essere trattati entrambi come Volante.

Simulare l’Ereditarietà Multipla: Poiché una classe può implementare più interfacce, si possono “ereditare” comportamenti da più fonti, superando il limite dell’ereditarietà singola di Java.

Testabilità: È molto più semplice creare “mock” o “stub” (implementazioni finte per i test) di un’interfaccia piuttosto che di una classe complessa. Questo facilita enormemente l’unit testing.

Ruolo nei Design Patterns:

Le interfacce sono fondamentali in moltissimi design pattern, che sono soluzioni collaudate a problemi ricorrenti nella progettazione del software.

  • Strategy Pattern: Permette di definire una famiglia di algoritmi, incapsularli in classi separate che implementano una stessa interfaccia, e renderli intercambiabili. Il client dipende solo dall’interfaccia e può cambiare “strategia” a runtime.
  • Factory Pattern: Un metodo “fabbrica” restituisce un oggetto di tipo interfaccia. Il client che riceve l’oggetto non sa (e non gli interessa sapere) quale classe concreta è stata istanziata, ma solo che rispetta il contratto dell’interfaccia.
  • Observer Pattern: L‘“Observer” e il “Subject” comunicano tramite interfacce. Il Subject non conosce le classi concrete degli Observer, ma sa solo che implementano l’interfaccia Observer e quindi hanno un metodo update() da chiamare.
  • Dependency Injection: (Vedi sezione Spring Boot) Le dipendenze vengono iniettate come interfacce, permettendo di sostituire facilmente le implementazioni concrete (es. da un database reale a un database in-memory per i test).

Esercizi Interfacce

Difficoltà crescente: dalla semplice implementazione all’uso delle interfacce per il disaccoppiamento (Strategy Pattern).

  1. Esercizio 1 (Base): Implementare un’Interfaccia
    • Crea un’interfaccia Volante con un metodo vola(). Crea due classi, Uccello e Aereo, che implementano questa interfaccia. Ogni implementazione di vola() stamperà un messaggio diverso.
  2. Esercizio 2 (Facile): Polimorfismo con Interfacce
    • Nel main, crea una List<Volante>. Aggiungi un’istanza di Uccello e una di Aereo. Scrivi un ciclo che itera sulla lista e chiama il metodo vola() per ogni elemento.
  3. Esercizio 3 (Medio-Facile): Interfacce Multiple
    • Crea una seconda interfaccia Nuotante con un metodo nuota(). Crea una classe Anatra che implements Volante, Nuotante. Nel main, dimostra che un oggetto Anatra può essere inserito sia in una List<Volante> che in una List<Nuotante>.
  4. Esercizio 4 (Medio): Metodi default
    • Aggiungi un metodo default all’interfaccia Volante chiamato atterra(), che stampa “Atterraggio standard.”. Dimostra che le classi Uccello e Aereo “ereditano” questo metodo senza bisogno di modifiche. Poi, fai l’override di atterra() nella classe Aereo per fornire un’implementazione più specifica.
  5. Esercizio 5 (Medio-Difficile): Interfacce per il Disaccoppiamento
    • Crea un’interfaccia Logger con un metodo log(String messaggio). Crea due implementazioni: ConsoleLogger (stampa su console) e FileLogger (scrive su un file fittizio). Crea una classe Calcolatrice che nel costruttore accetta un Logger. Ogni volta che la Calcolatrice esegue un’operazione, usa il logger per registrare l’evento. Nel main, mostra come puoi passare alla stessa Calcolatrice prima un ConsoleLogger e poi un FileLogger senza cambiare una riga di codice della Calcolatrice.
  6. Esercizio 6 (Difficile): Strategy Pattern
    • Crea un’interfaccia StrategiaDiPrezzo con un metodo calcolaPrezzo(double prezzoBase). Crea due implementazioni: PrezzoStandard (restituisce prezzoBase) e PrezzoScontato (restituisce prezzoBase * 0.8). Crea una classe Carrello che ha un attributo StrategiaDiPrezzo. Aggiungi un metodo setStrategia(StrategiaDiPrezzo s) e un metodo getPrezzoFinale(double totale) che usa la strategia corrente per calcolare il prezzo. Dimostra come puoi cambiare la strategia di prezzo del carrello a runtime.

Principi SOLID con filosofia di Uncle Bob, definizioni precise

I principi SOLID sono un acronimo che raggruppa cinque principi fondamentali della progettazione orientata agli oggetti, promossi da Robert C. Martin (conosciuto come “Uncle Bob”). Lo scopo di questi principi è creare software più comprensibile, flessibile e manutenibile.

La Filosofia di Uncle Bob: L’idea centrale è che un cattivo design del software (codice “rigido”, “fragile”, “non riutilizzabile”) rallenta lo sviluppo più di qualsiasi altra cosa. Scrivere “codice pulito” e seguire principi come SOLID non è un esercizio accademico, ma una pratica professionale essenziale per gestire la complessità e permettere al software di evolvere nel tempo.


S - Single Responsibility Principle (Principio di Singola Responsabilità)

  • Definizione Precisa: “Una classe dovrebbe avere una, e una sola, ragione per cambiare”.
  • Filosofia: Questa “ragione per cambiare” è legata a un “attore” (un utente o uno stakeholder) che ha bisogno di una modifica. Se una classe gestisce sia la logica di business che la persistenza su database, ci sono due attori (es. il reparto business e gli amministratori del database) che potrebbero richiedere modifiche. Questo accoppia due responsabilità che dovrebbero essere separate. La classe dovrebbe occuparsi di una sola cosa.

O - Open/Closed Principle (Principio Aperto/Chiuso)

  • Definizione Precisa: “Le entità software (classi, moduli, funzioni, ecc.) dovrebbero essere aperte all’estensione, ma chiuse alla modifica”.
  • Filosofia: Dovremmo essere in grado di aggiungere nuove funzionalità a un sistema senza dover modificare il codice esistente e già testato. Questo si ottiene tipicamente attraverso l’uso di astrazioni (interfacce o classi astratte). Si estende il comportamento creando nuove classi che implementano queste astrazioni, piuttosto che aggiungendo if/else o switch al codice esistente.

L - Liskov Substitution Principle (Principio di Sostituzione di Liskov)

  • Definizione Precisa: “Le sottoclassi devono essere sostituibili alle loro superclassi senza alterare la correttezza del programma”.
  • Filosofia: Se hai una funzione che accetta un oggetto di tipo Superclasse, devi poterle passare un oggetto di qualsiasi Sottoclasse senza che la funzione si “rompa” o si comporti in modo inaspettato. Questo significa che la sottoclasse non deve restringere il comportamento della superclasse (es. sovrascrivendo un metodo per lanciare un’eccezione dove la superclasse non lo faceva).

I - Interface Segregation Principle (Principio di Segregazione delle Interfacce)

  • Definizione Precisa: “Nessun client dovrebbe essere costretto a dipendere da metodi che non usa”.
  • Filosofia: È meglio avere molte interfacce piccole e specifiche (“ruoli”) piuttosto che una grande interfaccia generica. Se una classe implementa un’interfaccia con metodi che non le servono, è un segnale di un cattivo design. Dividere l’interfaccia “grassa” in interfacce più piccole e mirate permette alle classi di implementare solo i contratti che le riguardano.

D - Dependency Inversion Principle (Principio di Inversione delle Dipendenze)

  • Definizione Precisa:
    1. “I moduli di alto livello non dovrebbero dipendere da moduli di basso livello. Entrambi dovrebbero dipendere da astrazioni.”
    2. “Le astrazioni non dovrebbero dipendere dai dettagli. I dettagli dovrebbero dipendere dalle astrazioni.”
  • Filosofia: Questo principio è alla base di un’architettura disaccoppiata. Il codice che contiene la logica di business importante (alto livello) non dovrebbe dipendere direttamente da dettagli implementativi come un database specifico o un servizio di rete (basso livello). Invece, entrambi dovrebbero “parlare” attraverso un’interfaccia (un’astrazione). Questo permette di scambiare i dettagli di basso livello (es. passare da un database MySQL a PostgreSQL) senza toccare la logica di alto livello. È il principio che rende possibile la Dependency Injection.

Collections e Stream API

ArrayList

ArrayList: L’Array Dinamico

ArrayList in Java è un’implementazione dell’interfaccia List che utilizza un array interno per memorizzare gli elementi. La sua caratteristica principale è quella di essere un “array ridimensionabile” o “dinamico”: la sua dimensione può crescere e diminuire a seconda delle necessità.

Algoritmi Interni:

  • Array di Supporto: Internamente, ArrayList contiene un array di oggetti (es. Object[] elementData).
  • Accesso (get/set): L’accesso a un elemento tramite indice (get(int index)) è un’operazione estremamente veloce. Poiché si tratta di un array, l’indirizzo di memoria dell’elemento i può essere calcolato direttamente, risultando in una complessità temporale di O(1) (tempo costante).
  • Aggiunta (add):
    1. Caso Migliore: Se c’è ancora spazio nell’array interno, l’elemento viene semplicemente aggiunto alla fine, e un contatore size viene incrementato. Questa operazione è O(1).
    2. Caso Peggiore (Ridimensionamento): Se l’array interno è pieno e si cerca di aggiungere un nuovo elemento, ArrayList esegue un’operazione di ridimensionamento:
      • Crea un nuovo array più grande (tipicamente il 50% più grande del precedente).
      • Copia tutti gli elementi dal vecchio array al nuovo array.
      • Aggiunge il nuovo elemento alla fine del nuovo array.
      • Il riferimento interno viene aggiornato per puntare al nuovo array, e il vecchio viene scartato (e successivamente raccolto dal Garbage Collector). Questa operazione di copia ha una complessità di O(n), dove n è il numero di elementi correnti.

Performance e Considerazioni Pratiche:

  • Vantaggi:
    • Accesso Rapido: Eccellente per letture e scritture basate su indice (O(1)).
    • Dinamico: Gestisce automaticamente la dimensione.
  • Svantaggi:
    • Inserimenti/Rimozioni al Centro: Inserire o rimuovere un elemento all’inizio o al centro della lista è costoso (O(n)). Questo perché tutti gli elementi successivi devono essere spostati per fare spazio o per chiudere il buco lasciato.
    • Overhead di Memoria: Potrebbe esserci dello spazio sprecato nell’array interno se la capacità è molto più grande del numero di elementi effettivi.
  • Ottimizzazione: Se si conosce in anticipo il numero di elementi che verranno inseriti, è una buona pratica creare l’ArrayList con una capacità iniziale specifica (new ArrayList<>(capacita)) per evitare ridimensionamenti intermedi e migliorare le prestazioni.

Esercizi ArrayList

Difficoltà crescente: dall’uso base alla comprensione delle implicazioni di performance e all’uso di iteratori.

  1. Esercizio 1 (Base): Operazioni CRUD
    • Crea un ArrayList di String. Esegui le seguenti operazioni:
      • Aggiungi 3 nomi (add).
      • Leggi e stampa il secondo nome (get).
      • Aggiorna il primo nome con un nuovo valore (set).
      • Rimuovi l’ultimo nome (remove).
      • Stampa la lista finale.
  2. Esercizio 2 (Facile): Iterazione
    • Crea un ArrayList di Integer e riempilo con 5 numeri. Scrivi tre cicli diversi per stampare tutti gli elementi: un ciclo for classico con indice, un ciclo for-each e un’iterazione usando forEach con una lambda expression (lista.forEach(n -> System.out.println(n));).
  3. Esercizio 3 (Medio-Facile): Ricerca e Contenuto
    • Crea una lista di stringhe. Chiedi all’utente di inserire un nome. Usa il metodo contains() per verificare se il nome è presente. Se lo è, usa indexOf() per trovare la sua posizione e stamparla.
  4. Esercizio 4 (Medio): Performance di Inserimento
    • Crea un ArrayList e un LinkedList di Integer. Scrivi un ciclo che aggiunge 100.000 numeri all’inizio di ogni lista (list.add(0, i);). Misura il tempo impiegato da entrambe le liste usando System.nanoTime() prima e dopo il ciclo. Spiega la drastica differenza di performance.
  5. Esercizio 5 (Medio-Difficile): Rimozione durante l’Iterazione (Errore Comune)
    • Crea un ArrayList di Integer da 1 a 10. Prova a rimuovere tutti i numeri pari usando un ciclo for-each. Osserva la ConcurrentModificationException. Spiega perché accade.
  6. Esercizio 6 (Difficile): Rimozione Corretta con Iterator
    • Risolvi l’esercizio 5. Usa un Iterator per scorrere la lista. Quando trovi un numero pari, usa il metodo iterator.remove() per rimuoverlo in modo sicuro. In alternativa, mostra come risolvere il problema usando il metodo removeIf con una lambda expression, che è l’approccio moderno e più conciso.

HashMap

Teoria delle Hash Table:

Una HashMap è un’implementazione dell’interfaccia Map che si basa sulla struttura dati della hash table. Permette di memorizzare coppie chiave-valore e offre prestazioni eccezionali (in media O(1), tempo costante) per le operazioni di inserimento (put), recupero (get) e rimozione (remove).

Il meccanismo si basa su tre concetti:

  1. Hashing: Quando si inserisce una coppia (K, V), HashMap prende la chiave K e calcola un valore intero chiamato hash code tramite il metodo hashCode() della chiave. Questo hash code viene poi elaborato da una funzione di hashing interna per determinare un indice in un array interno (chiamato table o buckets).
  2. Array di Bucket: L’array interno è una serie di “secchielli” (bucket). L’indice calcolato determina in quale bucket la coppia chiave-valore verrà memorizzata.
  3. Recupero: Quando si chiede il valore associato a una chiave (get(K)), HashMap ricalcola l’hash code della chiave, trova l’indice del bucket corretto e cerca la chiave in quel bucket.

Gestione delle Collisioni:

Il problema sorge quando due chiavi diverse producono lo stesso indice di bucket. Questo evento è chiamato collisione. È inevitabile, dato che il numero di possibili chiavi è virtualmente infinito, mentre il numero di bucket è finito.

Java HashMap gestisce le collisioni principalmente con una tecnica chiamata Separate Chaining (Concatenamento Separato):

  1. Struttura del Bucket: Ogni bucket dell’array non contiene un singolo elemento, ma la testa di una lista concatenata (LinkedList) di nodi.
  2. In caso di Collisione: Se due chiavi mappano allo stesso bucket, la nuova coppia chiave-valore viene semplicemente aggiunta come un nuovo nodo nella lista concatenata di quel bucket.
  3. Ricerca in un Bucket: Quando si cerca una chiave in un bucket che contiene più nodi, la HashMap deve scorrere la lista concatenata e usare il metodo equals() per trovare la chiave esatta.

Ottimizzazione da Java 8 (Tree-ification):

Per mitigare il degrado delle prestazioni nel caso di molte collisioni nello stesso bucket (che porterebbe la ricerca a O(n)), da Java 8 in poi è stata introdotta un’importante ottimizzazione:

  • Se il numero di nodi in un bucket supera una certa soglia (TREEIFY_THRESHOLD, di default 8), la lista concatenata di quel bucket viene convertita in un albero rosso-nero (Red-Black Tree).
  • Questo migliora la complessità della ricerca nel caso peggiore da O(n) a O(log n), dove n è il numero di elementi nel bucket.

Importanza di hashCode() e equals():

Il corretto funzionamento di una HashMap dipende criticamente dal contratto tra i metodi hashCode() e equals() della chiave:

  1. Se due oggetti sono uguali secondo equals(), devono restituire lo stesso hashCode().
  2. Se due oggetti hanno lo stesso hashCode(), non è detto che siano uguali secondo equals() (questa è una collisione). Se questo contratto non viene rispettato, la HashMap avrà un comportamento imprevedibile.

Esercizi HashMap

Difficoltà crescente: dall’uso base alla creazione di una classe personalizzata come chiave, comprendendo il contratto hashCode/equals.

  1. Esercizio 1 (Base): Operazioni CRUD
    • Crea una HashMap<String, String> per memorizzare le capitali degli stati (<Stato, Capitale>).
      • Inserisci 3 coppie stato-capitale (put).
      • Recupera e stampa la capitale di uno stato (get).
      • Verifica se uno stato è presente (containsKey).
      • Rimuovi una coppia (remove).
      • Stampa la mappa finale.
  2. Esercizio 2 (Facile): Iterazione sulla Mappa
    • Crea una HashMap<String, Integer> che mappa nomi di prodotti a prezzi. Scrivi tre modi per iterare e stampare il contenuto:
      • Iterando sul keySet() (insieme delle chiavi).
      • Iterando sui values() (collezione dei valori).
      • Iterando sull’ entrySet() (insieme delle coppie chiave-valore), che è il modo più efficiente.
  3. Esercizio 3 (Medio-Facile): Calcolo delle Frequenze
    • Scrivi un programma che prende un testo (una String) e usa una HashMap<Character, Integer> per contare la frequenza di ogni carattere nel testo. Per ogni carattere, se non è già nella mappa, inseriscilo con valore 1; altrimenti, incrementa il suo valore.
  4. Esercizio 4 (Medio): Gestire Valori Mancanti
    • Migliora l’esercizio 3 usando il metodo getOrDefault. Invece di controllare con containsKey, puoi scrivere mappa.put(carattere, mappa.getOrDefault(carattere, 0) + 1);. Spiega come questa singola riga semplifica il codice.
  5. Esercizio 5 (Medio-Difficile): Chiave Personalizzata (Senza hashCode/equals)
    • Crea una classe Studente con id e nome. Crea una HashMap<Studente, Integer> per mappare studenti a voti. Crea due oggetti Studente distinti ma con gli stessi dati (new Studente(1, "Mario") e new Studente(1, "Mario")). Usa il primo oggetto per inserire un voto nella mappa. Prova a recuperare il voto usando il secondo oggetto. Osserva che fallisce (get restituisce null). Spiega perché.
  6. Esercizio 6 (Difficile): Il Contratto hashCode/equals
    • Risolvi l’esercizio 5. Fai l’override dei metodi hashCode() e equals() nella classe Studente. La logica di equals dovrebbe confrontare gli id. La logica di hashCode dovrebbe basarsi sull’ id. Riesegui il test dell’esercizio 5 e osserva che ora funziona correttamente. Spiega l’importanza di questo contratto per il funzionamento delle hash table.

Stream API e paradigma funzionale, lazy evaluation, parallelizzazione

L’API Stream e il Paradigma Funzionale in Java:

Introdotta in Java 8, l’API Stream non è una struttura dati, ma un modo per elaborare sequenze di elementi da una sorgente (come una Collection, un array, o un file) in uno stile dichiarativo e funzionale.

  • Paradigma Imperativo vs. Dichiarativo:
    • Imperativo: Si descrive il “come” fare qualcosa, passo dopo passo (es. un ciclo for con contatori e condizioni).
    • Dichiarativo (con gli Stream): Si descrive il “cosa” si vuole ottenere, lasciando all’API i dettagli su come farlo. Il codice è più conciso, leggibile e meno soggetto a errori.

Caratteristiche Principali di uno Stream:

  1. Pipeline di Operazioni: Uno stream è una sequenza di operazioni che vengono applicate agli elementi. Queste operazioni sono divise in due categorie:
    • Operazioni Intermedie (Intermediate Operations): Trasformano uno stream in un altro stream. Esempi: filter() (seleziona elementi), map() (trasforma elementi), sorted(), distinct().
    • Operazioni Terminali (Terminal Operations): Producono un risultato o un “side-effect”. Invocare un’operazione terminale avvia l’elaborazione dell’intero pipeline. Esempi: forEach(), collect() (raccoglie gli elementi in una collezione), reduce(), count().

Lazy Evaluation (Valutazione Pigra):

Questa è una delle ottimizzazioni più importanti degli stream.

  • Definizione: Le operazioni intermedie in un pipeline di stream sono pigre (lazy). Non vengono eseguite finché non viene invocata un’operazione terminale.
  • Come funziona: Invece di processare tutti gli elementi ad ogni passo del pipeline, lo stream elabora gli elementi verticalmente, uno alla volta (o in piccoli gruppi). Un elemento passa attraverso l’intero pipeline prima che venga processato l’elemento successivo.
  • Vantaggi:
    1. Efficienza: Si eseguono solo i calcoli strettamente necessari. Se un’operazione come findFirst() trova un risultato, lo stream può fermarsi senza processare il resto degli elementi (short-circuiting).
    2. Gestione di Stream Infiniti: La valutazione pigra permette di creare e operare su stream di dati potenzialmente infiniti (es. Stream.iterate(0, n -> n + 2) che genera tutti i numeri pari), a patto che ci sia un’operazione intermedia di “short-circuiting” (come limit()).

Esempio di Lazy Evaluation:

List<String> names = Arrays.asList("Anna", "Bruno", "Carlo", "Beatrice");
names.stream()
     .filter(name -> {
         System.out.println("Filtrando: " + name);
         return name.startsWith("B");
     })
     .map(name -> {
         System.out.println("Mappando: " + name);
         return name.toUpperCase();
     })
     .findFirst(); // Operazione terminale di short-circuiting

L’output non sarà: Filtrando: Anna Filtrando: Bruno Filtrando: Carlo e poi Mappando: ...

Ma sarà: Filtrando: Anna Filtrando: Bruno Mappando: Bruno

Lo stream si ferma non appena ha trovato il primo elemento che soddisfa la condizione, senza nemmeno considerare “Carlo”.

Parallelizzazione (Parallel Streams):

L’API Stream rende estremamente semplice parallelizzare le operazioni per sfruttare i processori multi-core.

  • Come si usa: Basta chiamare il metodo .parallel() su uno stream (o .parallelStream() su una collezione) per trasformarlo in uno stream parallelo.
  • Meccanismo Interno: Uno stream parallelo utilizza il framework Fork/Join (introdotto in Java 7). La sorgente dati viene suddivisa in sotto-problemi (fork), che vengono elaborati in parallelo da un pool di thread. I risultati parziali vengono poi ricombinati (join).
  • Quando usarli: La parallelizzazione non è sempre più veloce. Introduce un overhead per la suddivisione, la gestione dei thread e la ricombinazione dei risultati. È efficace su:
    • Grandi moli di dati.
    • Operazioni computazionalmente intensive su ogni elemento.
    • Sorgenti di dati che possono essere suddivise efficientemente (es. ArrayList è ottimo, LinkedList meno).
    • Operazioni stateless (senza stato) e associative.

Attenzione: L’uso di stream paralleli con operazioni stateful (che modificano uno stato condiviso) può portare a risultati non deterministici e a problemi di concorrenza se non gestito correttamente.

Esercizi Stream API

Difficoltà crescente: da semplici operazioni a pipeline complesse con collectors e parallelizzazione.

  1. Esercizio 1 (Base): filter e forEach
    • Data una List<String> di nomi, usa uno stream per filtrare solo i nomi che iniziano con la lettera “A” e stamparli a video.
  2. Esercizio 2 (Facile): map e collect
    • Data una List<String> di parole, usa uno stream per creare una nuova lista (List<Integer>) contenente la lunghezza di ogni parola. L’operazione finale dovrà essere collect(Collectors.toList()).
  3. Esercizio 3 (Medio-Facile): Chaining (Pipeline)
    • Data una List<Prodotto> (con attributi nome, categoria, prezzo), scrivi un singolo pipeline di stream che:
      1. Filtra i prodotti della categoria “Elettronica”.
      2. Filtra quelli con un prezzo superiore a 100.
      3. Estrae (mappa) solo i nomi dei prodotti.
      4. Raccoglie i nomi in una nuova lista.
  4. Esercizio 4 (Medio): Operazioni Terminali diverse (findFirst, anyMatch)
    • Data una List<Integer>, usa uno stream per:
      1. Verificare se esiste almeno un numero maggiore di 50 (anyMatch).
      2. Trovare il primo numero pari e stamparlo (filter e findFirst). Gestisci il caso in cui non esista con Optional.
  5. Esercizio 5 (Medio-Difficile): reduce e mapToInt
    • Data una List<Integer>, calcola la somma di tutti i numeri in due modi diversi usando gli stream:
      1. Usando il metodo reduce.
      2. Usando mapToInt per convertire lo Stream<Integer> in un IntStream e poi chiamando il metodo sum(), che è più efficiente.
  6. Esercizio 6 (Difficile): Raggruppamento con Collectors.groupingBy
    • Data la List<Prodotto> dell’esercizio 3, usa uno stream e un collector per creare una Map<String, List<Prodotto>> dove le chiavi sono le categorie e i valori sono le liste dei prodotti appartenenti a quella categoria. Questa è una delle operazioni più potenti dei collectors.

Gestione Errori

Eccezioni con evoluzione storica, stack unwinding, performance impact

Evoluzione Storica della Gestione degli Errori:

Prima della gestione strutturata delle eccezioni, la gestione degli errori nel software era macchinosa e incline a errori:

  1. Codici di Ritorno (Return Codes): Le funzioni restituivano valori speciali (es. -1, 0 o null) per indicare un errore. Il codice chiamante era responsabile di controllare questi valori con una serie di if-else. Questo mescolava la logica di business con la gestione degli errori, rendendo il codice difficile da leggere e manutenere.
  2. Variabili di Errore Globali: Alcuni sistemi impostavano una variabile globale di errore. Anche questo approccio era fragile, specialmente in ambienti multithread.

Linguaggi come C++ e, successivamente, Java hanno introdotto la gestione strutturata delle eccezioni con i blocchi try-catch. Questo ha permesso di:

  • Separare la Logica di Business dalla Gestione degli Errori: Il codice “felice” (happy path) viene messo nel blocco try, mentre la gestione delle situazioni anomale viene delegata ai blocchi catch.
  • Propagare gli Errori: Un errore può essere “lanciato” (throw) da un metodo e catturato da un chiamante più in alto nella catena di chiamate, senza che ogni metodo intermedio debba gestire esplicitamente l’errore.

Stack Unwinding (Srotolamento dello Stack):

Quando un’eccezione viene lanciata (throw), il normale flusso di esecuzione viene interrotto e il runtime di Java inizia un processo chiamato stack unwinding.

  1. Creazione dell’Oggetto Eccezione: Viene creato un oggetto di tipo Exception (o una sua sottoclasse) che contiene informazioni sull’errore, inclusa la stack trace (la sequenza di chiamate a metodo che ha portato all’errore).
  2. Ricerca di un Handler: Il runtime cerca, all’interno del metodo corrente, un blocco catch che possa gestire quel tipo di eccezione.
  3. Srotolamento dello Stack:
    • Se non viene trovato un catch appropriato nel metodo corrente, il metodo termina bruscamente.
    • Il controllo torna al metodo chiamante (il frame precedente nello stack di chiamate).
    • Il runtime ripete la ricerca di un blocco catch nel chiamante.
    • Questo processo continua, “srotolando” lo stack di chiamate un frame alla volta.
  4. Gestione o Terminazione:
    • Se viene trovato un blocco catch compatibile, lo stack smette di srotolarsi e il codice all’interno del blocco catch viene eseguito.
    • Se l’eccezione arriva fino al metodo main e non viene catturata, il thread corrente termina e la stack trace dell’eccezione viene solitamente stampata sulla console.

Il Blocco finally: Durante lo stack unwinding, se un metodo ha un blocco finally, il codice al suo interno viene sempre eseguito, indipendentemente dal fatto che l’eccezione sia stata catturata o meno in quel metodo. Questo è cruciale per il rilascio di risorse (es. chiudere file o connessioni di rete).

Impatto sulle Prestazioni:

La gestione delle eccezioni in Java ha un impatto sulle prestazioni, ma è importante capire dove e perché.

  • Costo Zero (o quasi) nel try Block: Entrare e uscire da un blocco try quando non viene lanciata alcuna eccezione ha un costo di performance trascurabile. I moderni JIT compiler sono molto efficienti nell’ottimizzare questo percorso.
  • Costo Elevato quando un’Eccezione viene Lanciata: L’atto di creare e lanciare un’eccezione è costoso. Le ragioni principali sono:
    1. Creazione dell’Oggetto Eccezione: Richiede l’allocazione di memoria sull’heap.
    2. Cattura dello Stack Trace: Questa è l’operazione più costosa. La JVM deve percorrere l’intero stack di chiamate per costruire la stack trace.
    3. Stack Unwinding: Il processo di ricerca di un handler ha un costo computazionale.

Best Practice:

Le eccezioni dovrebbero essere usate per condizioni eccezionali e anomale, non per il normale controllo di flusso del programma. Usare le eccezioni per, ad esempio, gestire la fine di un ciclo o per restituire un risultato “atteso” è un anti-pattern che degrada inutilmente le prestazioni.

Esercizio Eccezioni

Difficoltà crescente: dalla gestione base alla creazione di eccezioni personalizzate e all’uso di try-with-resources.

  1. Esercizio 1 (Base): try-catch
    • Scrivi un programma che divide due numeri. Metti l’operazione di divisione in un blocco try e cattura la ArithmeticException che si verifica quando si tenta di dividere per zero, stampando un messaggio di errore appropriato.
  2. Esercizio 2 (Facile): Blocco finally
    • Modifica l’esercizio 1. Aggiungi un blocco finally che stampa “Operazione conclusa.”. Esegui il programma sia con una divisione valida sia con una divisione per zero e osserva che il blocco finally viene eseguito in entrambi i casi.
  3. Esercizio 3 (Medio-Facile): Catch Multipli
    • Crea un array di interi. Scrivi un programma che chiede all’utente un indice e un divisore. Prova a dividere l’elemento all’indice specificato per il divisore. Gestisci separatamente la ArrayIndexOutOfBoundsException e la ArithmeticException con due blocchi catch distinti.
  4. Esercizio 4 (Medio): Checked vs. Unchecked Exceptions
    • Scrivi un metodo leggiFile(String nomeFile) che potrebbe lanciare una IOException (che è una checked exception). Non gestirla all’interno del metodo, ma aggiungi throws IOException alla firma del metodo. Chiama questo metodo dal main e osserva che il compilatore ti costringe a gestire l’eccezione con un try-catch o a propagarla ulteriormente.
  5. Esercizio 5 (Medio-Difficile): Eccezioni Personalizzate
    • Crea una tua classe di eccezione personalizzata (checked) SaldoInsufficienteException che estende Exception. Modifica la classe ContoCorrente (da un esercizio precedente) in modo che il metodo preleva(double importo) lanci questa eccezione se il saldo non è sufficiente, invece di stampare solo un messaggio.
  6. Esercizio 6 (Difficile): try-with-resources
    • Scrivi un programma che apre un file per la lettura usando BufferedReader e FileReader. Invece di usare un blocco finally per assicurarti che il reader venga chiuso (reader.close()), usa il costrutto try-with-resources. Dimostra che è più conciso e sicuro, in quanto la chiusura della risorsa è automatica.

Spring Boot

Storia Spring Boot, problemi pre-Boot, principi architetturali

Contesto Pre-Spring Boot: La Complessità di Spring Framework

Spring Framework, nato nei primi anni 2000, ha rivoluzionato lo sviluppo Java introducendo il principio di Dependency Injection e un approccio basato su POJO (Plain Old Java Object), semplificando notevolmente lo sviluppo di applicazioni enterprise rispetto ai complessi EJB (Enterprise JavaBeans) dell’epoca.

Tuttavia, con il tempo, la configurazione di un’applicazione Spring, specialmente per il web, era diventata un compito complesso e laborioso:

  • XML Configuration Hell: Le prime versioni di Spring si basavano pesantemente su file di configurazione XML per definire i “bean” (oggetti gestiti da Spring) e le loro dipendenze. Per applicazioni di grandi dimensioni, questi file diventavano enormi, difficili da leggere e da mantenere.
  • Gestione delle Dipendenze: Era necessario dichiarare manualmente nel file di build (es. pom.xml per Maven) ogni singola dipendenza richiesta, incluse le librerie transitive, e assicurarsi che le versioni fossero compatibili tra loro, un compito spesso frustrante e soggetto a errori.
  • Configurazione Boilerplate: Per creare una semplice applicazione web, era necessario configurare manualmente il DispatcherServlet, il ViewResolver, i DataSource, EntityManagerFactory, TransactionManager e molto altro. Molto di questo codice di configurazione era ripetitivo e identico in quasi tutti i progetti.
  • Deployment Complesso: Le applicazioni web dovevano essere impacchettate come file WAR (Web Application Archive) e deployate su un server applicativo esterno (come Tomcat o JBoss), che doveva essere installato e configurato separatamente.

La Nascita di Spring Boot:

Spring Boot, rilasciato nel 2014, non è un nuovo framework, ma un’evoluzione di Spring progettata per risolvere questi problemi e semplificare radicalmente il processo di creazione e avvio di applicazioni Spring.

Principi Architetturali e Filosofia:

La filosofia di Spring Boot si basa su alcuni principi chiave:

  1. “Convention over Configuration” (Convenzione sulla Configurazione): Spring Boot adotta un approccio “opinionated” (con delle opinioni). Analizza il classpath dell’applicazione e, in base alle librerie che trova, configura automaticamente la maggior parte delle funzionalità comuni.

    • Esempio: Se trova la dipendenza spring-boot-starter-web, Spring Boot assume che si stia costruendo un’applicazione web e configura automaticamente Tomcat, il DispatcherServlet e altre componenti essenziali senza che lo sviluppatore debba scrivere una sola riga di configurazione.
  2. Auto-Configuration: Questo è il meccanismo principale dietro la “Convention over Configuration”. Spring Boot fornisce una vasta gamma di classi di @Configuration condizionali che si attivano solo se determinate condizioni sono soddisfatte (es. una certa classe è presente nel classpath, una certa proprietà è definita, ecc.).

  3. Starter Dependencies: Per risolvere il problema della gestione delle dipendenze, Spring Boot introduce i “pom starter”. Questi sono dei descrittori di build (es. spring-boot-starter-data-jpa, spring-boot-starter-test) che raggruppano tutte le dipendenze comuni necessarie per una specifica funzionalità. Includendo un singolo starter, si ottengono tutte le librerie necessarie (incluse quelle transitive) in versioni testate e compatibili tra loro.

  4. Server Web Embedded: Spring Boot permette di includere un server web (come Tomcat, Jetty o Undertow) direttamente all’interno dell’applicazione. Questo significa che l’applicazione può essere eseguita come un semplice file JAR eseguibile (java -jar mia-app.jar), eliminando la necessità di un server applicativo esterno. Questo semplifica enormemente lo sviluppo, il testing e il deployment, ed è un pilastro delle architetture a microservizi.

  5. Metriche e Salute dell’Applicazione “Out-of-the-Box”: Includendo lo starter spring-boot-starter-actuator, si ottengono immediatamente degli endpoint HTTP per monitorare lo stato di salute (/health), le metriche (/metrics), la configurazione (/configprops) e molto altro, funzionalità essenziali per applicazioni pronte per la produzione.

In sintesi, Spring Boot non sostituisce Spring, ma ne semplifica drasticamente l’uso, permettendo agli sviluppatori di concentrarsi sulla logica di business piuttosto che sulla configurazione dell’infrastruttura.

Esercizi Spring Boot Fundamentals & Starters

Difficoltà crescente: dalla creazione di un’applicazione base alla comprensione dell’auto-configurazione.

  1. Esercizio 1 (Base): “Hello, Spring Boot!”
    • Usa start.spring.io per generare un nuovo progetto Maven con la sola dipendenza “Spring Web”. Importalo nel tuo IDE. Crea un @RestController con un singolo metodo mappato su /hello che restituisce la stringa “Hello, Spring Boot!”. Avvia l’applicazione e verifica il risultato nel browser.
  2. Esercizio 2 (Facile): Utilizzo delle Proprietà
    • Nel file application.properties, cambia la porta del server embedded a 8090 usando la proprietà server.port. Riavvia l’applicazione e verifica che ora risponda sulla nuova porta.
  3. Esercizio 3 (Medio-Facile): Aggiungere un Altro Starter
    • Aggiungi lo starter spring-boot-starter-actuator al tuo pom.xml. Riavvia l’applicazione. Accedi agli endpoint che Actuator espone automaticamente, come /actuator/health e /actuator/info. Questo dimostra come Spring Boot aggiunge funzionalità in base agli starter presenti.
  4. Esercizio 4 (Medio): Creare una Classe di Configurazione Personalizzata
    • Crea una classe AppInfo con due campi stringa, name e version. Annotala con @ConfigurationProperties(prefix = "app") e @Configuration. Abilitala con @EnableConfigurationProperties sulla classe principale. Definisci le proprietà app.name e app.version in application.properties.
  5. Esercizio 5 (Medio-Difficile): Usare la Configurazione Personalizzata
    • Iniettare il bean AppInfo creato nell’esercizio precedente all’interno del tuo controller. Modifica l’endpoint /hello in modo che restituisca un messaggio di benvenuto che include il nome e la versione dell’applicazione presi dalla configurazione.
  6. Esercizio 6 (Difficile): Analizzare l’Auto-Configurazione
    • Avvia l’applicazione con il flag --debug (o imposta debug=true in application.properties). Analizza l’output della console per vedere il “Auto-configuration report”. Trova le sezioni “Positive matches” e “Negative matches” e cerca di capire perché Spring Boot ha deciso di configurare (o non configurare) determinati bean, ad esempio DataSourceAutoConfiguration.

Dependency Injection con storia IoC, tipi iniezione, meccanismo container

Storia: Inversion of Control (IoC)

Inversion of Control (IoC) è un principio di design del software di alto livello.

  • Controllo Tradizionale: In un programma tradizionale, il codice dello sviluppatore ha il controllo. È il nostro codice che crea gli oggetti, li collega tra loro e decide quando chiamare i metodi. Il nostro codice “controlla” il flusso.

    public class MioServizio {
        private AltroServizio dipendenza;
        public MioServizio() {
            // Io controllo la creazione della mia dipendenza
            this.dipendenza = new AltroServizioImpl();
        }
    }
  • Inversione del Controllo: Con IoC, questo controllo viene invertito. Non è più il nostro codice a creare e gestire le dipendenze, ma un’entità esterna, tipicamente un framework o un container. Il nostro codice definisce solo di cosa ha bisogno, e il container si occupa di fornirglielo al momento giusto. La filosofia è “Don’t call us, we’ll call you” (Non chiamarci, ti chiameremo noi).

Dependency Injection (DI): L’Implementazione di IoC

Dependency Injection (DI) è il pattern di design più comune per implementare il principio di IoC. È il processo attraverso il quale il container “inietta” le dipendenze (cioè, gli oggetti di cui una classe ha bisogno) in un’altra classe.

Il Meccanismo del Container IoC di Spring (ApplicationContext):

Il cuore di Spring è il suo container IoC, rappresentato principalmente dall’interfaccia ApplicationContext. Il suo lavoro si svolge in due fasi principali:

  1. Fase di Configurazione:

    • Scansione dei Componenti: Il container scansiona il classpath alla ricerca di classi annotate con stereotipi come @Component, @Service, @Repository, @RestController.
    • Creazione delle “Bean Definition”: Per ogni classe trovata, crea una sorta di “ricetta” (una BeanDefinition) che descrive come creare l’oggetto (il “bean”): qual è la sua classe, qual è il suo scope (es. singleton, prototype), e quali sono le sue dipendenze.
  2. Fase di Istanziazione e Iniezione:

    • Creazione dei Bean: Quando un bean è richiesto (o all’avvio, nel caso dei singleton), il container usa la BeanDefinition per creare un’istanza dell’oggetto.
    • Risoluzione e Iniezione delle Dipendenze: Il container esamina le dipendenze del bean appena creato. Cerca nel suo registro un bean che corrisponda al tipo richiesto e lo “inietta” nell’oggetto.
    • Gestione del Ciclo di Vita: Il container gestisce l’intero ciclo di vita del bean, dalla creazione alla distruzione.

Tipi di Iniezione in Spring:

Spring offre tre modi principali per iniettare le dipendenze.

  1. Constructor Injection (Iniezione tramite Costruttore):

    • Come funziona: Le dipendenze vengono dichiarate come parametri del costruttore della classe.
    @Service
    public class MioServizio {
        private final AltroServizio dipendenza;
    
        // Spring userà questo costruttore per l'iniezione
        @Autowired
        public MioServizio(AltroServizio dipendenza) {
            this.dipendenza = dipendenza;
        }
    }
    • Vantaggi:
      • Immutabilità: Le dipendenze possono essere dichiarate final, garantendo che non possano essere cambiate dopo la creazione dell’oggetto.
      • Dipendenze Esplicite: Le dipendenze obbligatorie sono chiare fin dalla firma del costruttore. Un oggetto non può essere creato in uno stato invalido (senza le sue dipendenze).
      • Raccomandato da Spring: È il metodo di iniezione preferito e raccomandato dal team di Spring.
  2. Setter Injection (Iniezione tramite Metodo Setter):

    • Come funziona: Le dipendenze vengono fornite attraverso metodi setter pubblici.
    @Service
    public class MioServizio {
        private AltroServizio dipendenza;
    
        @Autowired
        public void setDipendenza(AltroServizio dipendenza) {
            this.dipendenza = dipendenza;
        }
    }
    • Uso: Utile per dipendenze opzionali o per permettere la riconfigurazione del bean a runtime.
  3. Field Injection (Iniezione su Campo):

    • Come funziona: L’annotazione @Autowired viene posta direttamente sul campo.
    @Service
    public class MioServizio {
        @Autowired
        private AltroServizio dipendenza;
    }
    • Vantaggi: Molto concisa.
    • Svantaggi (e perché è sconsigliata):
      • Nasconde le Dipendenze: Non è chiaro quali siano le dipendenze necessarie guardando i costruttori o i metodi pubblici.
      • Difficoltà di Test: Rende difficile l’unit testing, poiché è necessario usare la reflection per impostare le dipendenze finte (mock) nel test.
      • Incoraggia Cattive Pratiche: Può portare a classi con troppe dipendenze.
      • Violazione dell’Immutabilità: Non è possibile dichiarare i campi come final.

Esercizi Dependency Injection & IoC

Difficoltà crescente: dall’iniezione base alla gestione di ambiguità e scope dei bean.

  1. Esercizio 1 (Base): Field Injection
    • Crea una classe MessageService annotata con @Service che ha un metodo getMessage() che restituisce “Messaggio di servizio”. Crea un MessageController annotato con @RestController e inietta il MessageService usando @Autowired direttamente sul campo. Crea un endpoint che chiami getMessage() e restituisca il risultato.
  2. Esercizio 2 (Facile): Constructor Injection
    • Riscrivi l’esercizio 1 utilizzando la constructor injection, che è la pratica raccomandata. Rendi il campo MessageService nel controller final. Spiega i vantaggi di questo approccio (immutabilità, dipendenze esplicite).
  3. Esercizio 3 (Medio-Facile): Iniezione di Interfacce
    • Crea un’interfaccia GreetingService. Crea una classe GreetingServiceImpl che implementa l’interfaccia. Nel controller, inietta l’interfaccia GreetingService, non l’implementazione concreta. Questo dimostra il principio di “programmare verso un’interfaccia”.
  4. Esercizio 4 (Medio): Gestire l’Ambiguità con @Qualifier
    • Crea una seconda implementazione dell’interfaccia GreetingService chiamata FormalGreetingServiceImpl (es. una restituisce “Ciao!” e l’altra “Buongiorno.”). Annotale entrambe con @Service. Avvia l’applicazione e osserva l’errore NoUniqueBeanDefinitionException. Risolvi il problema annotando le implementazioni con @Qualifier("informal") e @Qualifier("formal") e usando @Qualifier nel punto di iniezione del controller per specificare quale bean usare.
  5. Esercizio 5 (Medio-Difficile): Bean Scopes
    • Crea un HitCounterService con un contatore interno (private int count = 0;) e un metodo incrementAndGet(). Annotalo con @Service e @Scope("session"). Inietta questo servizio in un controller e crea un endpoint che chiama incrementAndGet() e restituisce il risultato. Apri due browser diversi (o una finestra in incognito) e chiama l’endpoint da entrambi per dimostrare che ogni sessione ha il suo contatore separato.
  6. Esercizio 6 (Difficile): Configurazione Java-based con @Bean
    • Rimuovi l’annotazione @Service da una delle tue classi di servizio. Crea una classe di configurazione separata annotata con @Configuration. All’interno di questa classe, definisci un metodo annotato con @Bean che crea e restituisce manualmente un’istanza del tuo servizio. Dimostra che l’iniezione di dipendenza funziona ancora.

Annotazioni e metaprogrammazione, layer architecture, component scanning

Annotazioni e Metaprogrammazione:

Le annotazioni (come @Override, @Autowired, @Service) sono una forma di metadati, ovvero dati che descrivono altri dati (in questo caso, il codice stesso). In Java, le annotazioni non fanno nulla da sole; richiedono un processore di annotazioni che le legga e agisca di conseguenza.

Metaprogrammazione è l’idea di scrivere codice che legge, analizza o manipola altro codice. Spring fa un uso estensivo della metaprogrammazione:

  • Durante l’avvio, il container di Spring (processore) non esegue il tuo codice, ma lo analizza.
  • Legge le annotazioni (@Component, @Autowired, ecc.) per capire come i tuoi oggetti (bean) dovrebbero essere creati, configurati e collegati tra loro.
  • In base a questi metadati, genera dinamicamente il “collante” che tiene insieme l’applicazione.

Questo approccio permette di avere un codice di business pulito e disaccoppiato dalla logica di configurazione, che viene invece espressa in modo dichiarativo tramite le annotazioni.

Layered Architecture (Architettura a Strati):

Un’architettura a strati è un modo comune e robusto per organizzare il codice di un’applicazione. Ogni strato ha una responsabilità specifica e comunica solo con gli strati adiacenti (tipicamente, solo con lo strato sottostante). Spring Boot si adatta perfettamente a questo modello.

Una tipica architettura a 3 strati in un’applicazione web Spring Boot è:

  1. Presentation Layer (Strato di Presentazione):

    • Responsabilità: Gestire le richieste HTTP in entrata e le risposte in uscita. Tradurre i dati da/a formati come JSON.
    • Componenti Spring: Classi annotate con @RestController o @Controller. Questi sono i punti di ingresso dell’applicazione.
    • Non contiene logica di business. Chiama lo strato di servizio per eseguire le operazioni.
  2. Service Layer (o Business Layer - Strato di Servizio):

    • Responsabilità: Contenere la logica di business principale dell’applicazione. Coordina le operazioni, applica le regole di business e gestisce le transazioni.
    • Componenti Spring: Classi annotate con @Service.
    • È il cuore dell’applicazione. Riceve le chiamate dal Presentation Layer e utilizza il Data Access Layer per interagire con i dati.
  3. Data Access Layer (o Persistence Layer - Strato di Accesso ai Dati):

    • Responsabilità: Interagire con il database. Si occupa di tutte le operazioni CRUD (Create, Read, Update, Delete).
    • Componenti Spring: Interfacce che estendono JpaRepository (o altre interfacce di Spring Data) e sono annotate con @Repository.
    • Questo strato astrae la logica di accesso ai dati, nascondendo i dettagli di come i dati vengono memorizzati e recuperati.

Flusso di una Richiesta: Richiesta HTTPRestController (Presentation) → Service (Business) → Repository (Data Access) → Database

Component Scanning:

Come fa Spring a trovare queste classi (@RestController, @Service, @Repository) per gestirle? La risposta è il Component Scanning.

  • Punto di Partenza: L’annotazione @SpringBootApplication sulla classe principale del tuo progetto è in realtà un’annotazione composta che include @ComponentScan.
  • Meccanismo: Per impostazione predefinita, @ComponentScan dice a Spring di scansionare il pacchetto della classe principale e tutti i suoi sotto-pacchetti alla ricerca di classi annotate con uno “stereotipo” di componente.
  • Stereotipi: Le annotazioni principali che @ComponentScan cerca sono:
    • @Component (l’annotazione generica)
    • @Service (specializzazione di @Component per lo strato di servizio)
    • @Repository (specializzazione per lo strato di accesso ai dati, abilita anche la traduzione delle eccezioni specifiche del database)
    • @Controller / @RestController (specializzazione per lo strato di presentazione)
    • @Configuration (per le classi di configurazione)

Grazie al component scanning, basta annotare una classe e assicurarsi che si trovi nel pacchetto corretto (o in un suo sotto-pacchetto) perché Spring la rilevi, ne crei un’istanza e la gestisca come un bean, rendendola disponibile per la dependency injection.

Esercizi Layered Architecture & Component Scanning

Difficoltà crescente: dalla creazione di un singolo layer alla loro interconnessione completa e alla gestione dei DTO.

  1. Esercizio 1 (Base): Il Presentation Layer
    • Crea un’applicazione per gestire una lista di “Task”. Crea un TaskController (@RestController) con un metodo GET /tasks che restituisce una lista hardcoded di stringhe (es. “Studiare Spring”, “Fare la spesa”).
  2. Esercizio 2 (Facile): Aggiungere il Service Layer
    • Crea una classe TaskService (@Service). Sposta la logica che crea la lista di task dal controller al servizio. Il controller ora deve iniettare il TaskService e chiamare un suo metodo (es. findAll()) per ottenere i dati.
  3. Esercizio 3 (Medio-Facile): Aggiungere il Repository Layer (Mock)
    • Crea una classe TaskRepository (@Repository). Simula un database usando una Map<Long, String> interna. Il repository deve avere metodi come findAll(), findById(Long id), save(String task). Il TaskService ora deve usare il TaskRepository per gestire i dati.
  4. Esercizio 4 (Medio): Definire un Modello e un DTO
    • Crea una classe modello Task (un POJO) con id, description, completed. Il repository ora lavorerà con oggetti Task. Crea anche una classe TaskDTO che espone solo i campi che vuoi mostrare al client (es. omettendo l’id).
  5. Esercizio 5 (Medio-Difficile): Collegare tutti i Layer con DTO
    • Implementa il flusso completo:
      1. Il TaskController riceve/restituisce TaskDTO.
      2. Il TaskService riceve/restituisce TaskDTO, ma internamente li converte in entità Task per interagire con il repository.
      3. Il TaskRepository lavora esclusivamente con le entità Task.
    • Implementa un metodo POST /tasks che accetta un TaskDTO per creare un nuovo task.
  6. Esercizio 6 (Difficile): Personalizzare il Component Scan
    • Sposta tutte le classi del repository in un package completamente separato, al di fuori della gerarchia del package principale (es. com.example.data invece di com.example.demo.repository). Avvia l’app e osserva che fallisce perché non trova il bean del repository. Correggi il problema usando @ComponentScan(basePackages = {"com.example.demo", "com.example.data"}) sulla tua classe principale.

REST con storia protocolli, principi architetturali, Richardson model

Breve Storia dei Protocolli per API:

  • Anni ‘90 - RPC (Remote Procedure Call): L’idea era di chiamare una funzione su un server remoto come se fosse una funzione locale. Protocolli come CORBA e DCOM erano complessi e strettamente accoppiati.
  • Fine Anni ‘90 / Inizio 2000 - SOAP (Simple Object Access Protocol): Divenne lo standard per i “Web Services”. Basato su XML, definiva un formato di messaggistica rigido con uno schema (WSDL) che descriveva le operazioni disponibili. SOAP era robusto e standardizzato, ma anche molto verboso e complesso.
  • 2000 - La Nascita di REST: Roy Fielding, nella sua tesi di dottorato, definì lo stile architetturale REST (Representational State Transfer). Non è un protocollo, ma un insieme di vincoli e principi per la progettazione di sistemi distribuiti, basati sul modo in cui funziona il Web stesso (HTTP). REST emerse come un’alternativa più semplice, flessibile e scalabile a SOAP.

Principi Architetturali di REST:

REST si basa su sei vincoli fondamentali:

  1. Client-Server: Netta separazione tra il client (che si occupa dell’interfaccia utente) e il server (che si occupa della logica di business e della persistenza dei dati). Possono evolvere indipendentemente.
  2. Stateless (Senza Stato): Ogni richiesta dal client al server deve contenere tutte le informazioni necessarie per essere compresa ed eseguita. Il server non memorizza alcuno stato della sessione del client tra una richiesta e l’altra. Questo migliora la scalabilità e l’affidabilità.
  3. Cacheable (Memorizzabile nella Cache): Le risposte del server devono indicare se possono essere messe in cache dal client o da intermediari. Questo migliora le prestazioni e riduce il carico sul server.
  4. Uniform Interface (Interfaccia Uniforme): Questo è il vincolo chiave che distingue REST. Si basa su quattro sotto-principi:
    • Identificazione delle Risorse: Ogni “cosa” (un utente, un prodotto, un ordine) è una risorsa identificata da un URI univoco (es. /users/123).
    • Manipolazione delle Risorse tramite Rappresentazioni: Il client interagisce con le risorse attraverso le loro rappresentazioni (tipicamente JSON o XML).
    • Messaggi Auto-Descrittivi: Ogni messaggio (richiesta/risposta) contiene informazioni sufficienti per essere compreso (es. uso dei verbi HTTP, header Content-Type).
    • HATEOAS (Hypermedia as the Engine of Application State): La risposta del server dovrebbe contenere link (ipermedia) che guidano il client verso le possibili azioni successive.
  5. Layered System (Sistema a Strati): L’architettura può essere composta da più strati (es. proxy, gateway, load balancer) senza che il client se ne accorga. Ogni strato comunica solo con quello adiacente.
  6. Code-On-Demand (Opzionale): Il server può, opzionalmente, inviare codice eseguibile (es. script JavaScript) al client per estenderne le funzionalità.

Richardson Maturity Model:

Leonard Richardson ha proposto un modello per valutare quanto un’API sia “matura” e aderente ai principi REST. È diviso in quattro livelli:

  • Level 0: The Swamp of POX (Plain Old XML):

    • Usa HTTP solo come meccanismo di trasporto.
    • Tipicamente ha un singolo URI e usa un solo verbo HTTP (quasi sempre POST).
    • Le operazioni e i parametri sono contenuti nel corpo della richiesta (simile a RPC/SOAP).
    • Esempio: POST /service con corpo <getUsers/> o <createOrder>...</createOrder>.
  • Level 1: Resources:

    • Introduce il concetto di risorse con URI multipli e specifici.
    • Ogni risorsa ha un suo endpoint.
    • Continua a usare un singolo verbo HTTP (di solito POST) per tutte le operazioni.
    • Esempio: POST /users, POST /orders/123.
  • Level 2: HTTP Verbs:

    • Inizia a utilizzare i verbi HTTP in modo semanticamente corretto per le diverse operazioni sulle risorse.
    • Utilizza i codici di stato HTTP per indicare l’esito della richiesta (es. 200 OK, 201 Created, 404 Not Found).
    • Esempio:
      • GET /users (leggi la lista degli utenti)
      • POST /users (crea un nuovo utente)
      • PUT /users/123 (aggiorna l’utente 123)
      • DELETE /users/123 (elimina l’utente 123)
  • Level 3: Hypermedia Controls (HATEOAS):

    • Il livello più alto e la “gloria di REST”.

    • Le risposte del server includono non solo i dati, ma anche link (ipermedia) che indicano al client quali altre azioni può compiere o quali risorse correlate può esplorare.

    • Questo rende l’API “esplorabile”. Il client ha bisogno di conoscere solo l’URI di partenza e poi può “navigare” l’API seguendo i link forniti dal server.

    • Esempio di Risposta per un ordine:

          {
            "orderId": 123,
            "total": 50.00,
            "status": "SHIPPED",
            "_links": {
              "self": { "href": "/orders/123" },
              "customer": { "href": "/customers/45" },
              "tracking": { "href": "/orders/123/tracking" }
            }
          }

      Un’API che raggiunge il Livello 3 è considerata veramente “RESTful”.

Esercizi REST APIs & Richardson Maturity Model

Difficoltà crescente: dalla creazione di un’API RPC-style al pieno utilizzo dei verbi HTTP.

  1. Esercizio 1 (Base - RMM Level 0/1): API stile RPC
    • Crea un singolo endpoint POST /api/bookservice. Il corpo della richiesta JSON determina l’azione da compiere, es: { "action": "getAllBooks" } o { "action": "findBookById", "id": 1 }. L’endpoint usa degli if/else per decidere quale logica eseguire.
  2. Esercizio 2 (Facile - RMM Level 1): Risorse
    • Riscrivi l’esercizio 1 introducendo le risorse. Crea endpoint separati per ogni tipo di risorsa, ad esempio POST /api/books per ottenere tutti i libri e POST /api/books/1 per ottenere un libro specifico. Stai ancora usando solo il verbo POST.
  3. Esercizio 3 (Medio-Facile - RMM Level 2): Verbi HTTP (GET)
    • Modifica l’esercizio 2 per usare il verbo HTTP corretto. Cambia gli endpoint in GET /api/books e GET /api/books/{id}. Usa l’annotazione @PathVariable per recuperare l’id dall’URL.
  4. Esercizio 4 (Medio - RMM Level 2): Verbi HTTP (POST)
    • Implementa la creazione di un nuovo libro. Crea un metodo POST /api/books che accetta i dati del nuovo libro nel corpo della richiesta. Usa l’annotazione @RequestBody per mappare il JSON in un oggetto Java (DTO).
  5. Esercizio 5 (Medio-Difficile - RMM Level 2): Verbi HTTP (PUT & DELETE)
    • Completa le operazioni CRUD. Implementa:
      • PUT /api/books/{id} per aggiornare completamente un libro esistente.
      • DELETE /api/books/{id} per eliminare un libro.
  6. Esercizio 6 (Difficile - RMM Level 3): HATEOAS
    • Aggiungi lo starter spring-boot-starter-hateoas. Modifica il DTO del libro in modo che estenda RepresentationModel. Modifica il metodo GET /api/books/{id} per aggiungere un link “self” alla risposta, che punta all’endpoint stesso. Se un libro ha un autore, aggiungi un link alla risorsa dell’autore.

Codici HTTP con semantica precisa, patterns API design

La Semantica dei Codici di Stato HTTP:

I codici di stato HTTP non sono solo numeri, ma hanno una semantica precisa che comunica l’esito di una richiesta. Usarli correttamente è fondamentale per un buon design di API REST. Si dividono in cinque categorie:

  • 1xx (Informational): La richiesta è stata ricevuta, il processo continua. (Raramente usati nelle API REST).

  • 2xx (Success): La richiesta è stata ricevuta, compresa e accettata con successo.

    • 200 OK: Successo generico per una richiesta. Usato tipicamente per GET e PUT/PATCH riuscite.
    • 201 Created: La richiesta è stata soddisfatta e ha portato alla creazione di una nuova risorsa. La risposta dovrebbe includere un header Location con l’URI della nuova risorsa. Usato per POST.
    • 202 Accepted: La richiesta è stata accettata per l’elaborazione, ma l’elaborazione non è ancora completa (utile per operazioni asincrone).
    • 204 No Content: Il server ha elaborato con successo la richiesta, ma non c’è alcun contenuto da restituire nel corpo della risposta. Usato tipicamente per DELETE riuscite.
  • 3xx (Redirection): Sono necessarie ulteriori azioni da parte del client per completare la richiesta.

    • 301 Moved Permanently: La risorsa richiesta è stata spostata permanentemente a un nuovo URI.
    • 304 Not Modified: Usato in risposta a una richiesta GET condizionale (es. con header If-None-Match). Indica che la risorsa non è cambiata e il client può usare la sua versione in cache.
  • 4xx (Client Error): La richiesta contiene una sintassi errata o non può essere soddisfatta. L’errore è del client.

    • 400 Bad Request: Errore generico del client (es. JSON malformato, parametri di richiesta non validi). La risposta dovrebbe contenere dettagli sull’errore.
    • 401 Unauthorized: Il client deve autenticarsi per ottenere la risposta richiesta. Manca l’autenticazione o è fallita.
    • 403 Forbidden: Il client è autenticato, ma non ha i permessi per accedere alla risorsa.
    • 404 Not Found: Il server non ha trovato una risorsa corrispondente all’URI richiesto.
    • 405 Method Not Allowed: Il metodo HTTP usato nella richiesta non è supportato per quella risorsa (es. un PUT su un URI che accetta solo GET).
    • 409 Conflict: La richiesta non può essere completata a causa di un conflitto con lo stato attuale della risorsa (es. tentativo di creare una risorsa che esiste già).
  • 5xx (Server Error): Il server non è riuscito a soddisfare una richiesta apparentemente valida. L’errore è del server.

    • 500 Internal Server Error: Errore generico del server. Indica che si è verificata una condizione inaspettata che ha impedito al server di soddisfare la richiesta (es. un’eccezione non gestita).
    • 503 Service Unavailable: Il server non è attualmente in grado di gestire la richiesta a causa di un sovraccarico temporaneo o di manutenzione.

Patterns Comuni di API Design:

  1. Paginazione: Per le risorse che restituiscono liste di elementi (es. GET /products), è impraticabile restituire migliaia di record in una sola volta.

    • Pattern: Usare parametri di query come page e size (o limit e offset).
    • Esempio: GET /products?page=2&size=20 (restituisci 20 prodotti della seconda pagina).
    • Best Practice: Includere nella risposta metadati sulla paginazione (numero totale di elementi, numero totale di pagine) e, in un’API matura (HATEOAS), i link alle pagine successiva, precedente, prima e ultima.
  2. Ordinamento: Permettere al client di specificare come ordinare i risultati di una lista.

    • Pattern: Usare un parametro di query come sort.
    • Esempio: GET /products?sort=price,asc (ordina per prezzo, ascendente) o GET /products?sort=-price (ordina per prezzo, discendente).
  3. Filtraggio: Permettere al client di filtrare i risultati in base a determinati criteri.

    • Pattern: Usare parametri di query che corrispondono agli attributi della risorsa.
    • Esempio: GET /products?category=electronics&inStock=true.
  4. Selezione dei Campi (Field Selection): Permettere al client di richiedere solo i campi di cui ha bisogno, per ridurre la dimensione della risposta e il traffico di rete.

    • Pattern: Usare un parametro di query come fields.
    • Esempio: GET /users/123?fields=id,name,email.
  5. Versioning: Gestire le modifiche all’API che potrebbero “rompere” i client esistenti.

    • Pattern comune: Includere il numero di versione nell’URI.
    • Esempio: /api/v1/products, /api/v2/products.
    • Alternative: Usare un header HTTP personalizzato (es. Accept-Version: v1) o un content negotiation tramite l’header Accept.
  6. Formato degli Errori: Definire un formato di risposta JSON standard e consistente per tutti gli errori 4xx e 5xx, per facilitare la gestione degli errori da parte del client.

    • Esempio di corpo di una risposta 400:

      {
        "timestamp": "2025-08-27T10:00:00Z",
        "status": 400,
        "error": "Bad Request",
        "message": "Validation failed",
        "errors": [
          { "field": "email", "message": "must be a well-formed email address" }
        ],
        "path": "/users"
      }

Esercizi HTTP Codes & API Design Patterns

Difficoltà crescente: dall’uso dei codici di successo alla gestione centralizzata degli errori e alla paginazione.

  1. Esercizio 1 (Base): Codici di Successo Corretti
    • Rivedi l’API CRUD dell’esercizio precedente. Assicurati che:
      • POST restituisca 201 Created e l’header Location con l’URL della nuova risorsa. Usa ResponseEntity per costruire questa risposta.
      • DELETE restituisca 204 No Content.
      • GET e PUT restituiscano 200 OK.
  2. Esercizio 2 (Facile): Gestire il 404 Not Found
    • Modifica il metodo GET /api/books/{id}. Se il libro con l’ID specificato non viene trovato, il servizio deve lanciare un’eccezione. Il controller deve restituire una ResponseEntity con status 404 Not Found.
  3. Esercizio 3 (Medio-Facile): Gestione Centralizzata delle Eccezioni
    • Crea un’eccezione personalizzata ResourceNotFoundException. Modifica il servizio per lanciare questa eccezione. Crea una classe annotata con @RestControllerAdvice che gestisce globalmente la ResourceNotFoundException e restituisce una risposta 404 con un corpo JSON di errore standard.
  4. Esercizio 4 (Medio): Validazione e 400 Bad Request
    • Aggiungi la dipendenza spring-boot-starter-validation. Annota i campi del tuo DTO del libro con vincoli di validazione (es. @NotBlank, @Size(min=3) sul titolo). Aggiungi l’annotazione @Valid al @RequestBody nel controller. Estendi il tuo @RestControllerAdvice per gestire MethodArgumentNotValidException e restituire un errore 400 con i dettagli dei campi non validi.
  5. Esercizio 5 (Medio-Difficile): Paginazione
    • Modifica il tuo BookRepository per estendere PagingAndSortingRepository. Cambia il metodo GET /api/books in modo che accetti parametri page e size (@RequestParam). Usa un oggetto Pageable per recuperare una “pagina” di risultati dal repository. Restituisci un oggetto Page<Book> dal controller (Spring lo serializzerà in JSON con metadati di paginazione).
  6. Esercizio 6 (Difficile): Versioning dell’API
    • Implementa il versioning tramite URL. Crea un nuovo BookControllerV2 che gestisce le richieste su /api/v2/books. Questo controller deve restituire un BookDTOV2 con una struttura leggermente diversa (es. un campo in più o in meno). Il controller originale (BookController) deve essere mappato su /api/v1/books. Entrambe le versioni devono poter coesistere.

Spring Data JPA con impedance mismatch, evoluzione ORM, persistence context

Il Problema: Object-Relational Impedance Mismatch

Questo termine descrive le difficoltà concettuali e tecniche che sorgono quando si cerca di mappare il mondo della programmazione orientata agli oggetti (OOP) con il mondo dei database relazionali (RDBMS). I due paradigmi sono fondamentalmente diversi:

Paradigma OOPParadigma Relazionale
Struttura: Grafi di oggetti interconnessi.Struttura: Tabelle piatte di dati.
Relazioni: Riferimenti diretti tra oggetti in memoria.Relazioni: Chiavi esterne (foreign keys) che collegano righe di tabelle diverse.
Identità: Identità di memoria (due riferimenti possono puntare allo stesso oggetto).Identità: Chiave primaria (primary key).
Ereditarietà: Concetto nativo e fondamentale.Ereditarietà: Non esiste. Deve essere simulata con vari pattern (es. una tabella per gerarchia, una tabella per classe).
Granularità: Gli oggetti possono essere molto complessi e annidati.Granularità: Le righe di una tabella sono tipicamente “piatte”.

Questo “mismatch” costringe gli sviluppatori a scrivere molto codice “collante” (boilerplate) per tradurre i dati da un modello all’altro.

Evoluzione degli ORM (Object-Relational Mapping):

Gli strumenti ORM sono nati per risolvere l’impedance mismatch, agendo come un ponte tra i due mondi.

  1. JDBC (Java Database Connectivity): La base di tutto. È un’API a basso livello che permette a Java di eseguire query SQL su un database. Con JDBC puro, lo sviluppatore deve scrivere manualmente le query SQL, mappare i risultati (ResultSet) agli oggetti Java campo per campo, e gestire le connessioni e le transazioni. È molto verboso e soggetto a errori.
  2. Primi ORM (es. Hibernate, TopLink): Framework come Hibernate (nato nel 2001) hanno automatizzato questo processo. Lo sviluppatore definisce la mappatura tra le classi Java (chiamate entità) e le tabelle del database (spesso tramite XML o, successivamente, annotazioni). L’ORM si occupa di generare le query SQL, tradurre i risultati in oggetti e gestire le operazioni di salvataggio.
  3. JPA (Java Persistence API, ora Jakarta Persistence): Per standardizzare i vari ORM, è nata la specifica JPA. JPA non è un’implementazione, ma un insieme di interfacce e annotazioni standard (es. @Entity, @Id, @OneToMany). Hibernate è oggi l’implementazione (o “provider”) di JPA più diffusa. Usare JPA significa scrivere codice che non dipende da un ORM specifico, ma dallo standard.
  4. Spring Data JPA: Rappresenta un ulteriore livello di astrazione sopra JPA. L’obiettivo di Spring Data JPA è ridurre al minimo il codice boilerplate per lo strato di accesso ai dati. Invece di scrivere l’implementazione di una classe DAO (Data Access Object), lo sviluppatore deve solo definire un’interfaccia che estende JpaRepository.

Il Ruolo di Spring Data JPA:

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    // Spring Data JPA genererà automaticamente l'implementazione
    // di metodi come save(), findById(), findAll(), delete().

    // Basta definire la firma del metodo, e Spring Data JPA
    // scriverà la query per noi!
    Optional<User> findByEmail(String email);
}

Spring Data JPA analizza il nome del metodo (findByEmail), capisce che deve creare una query che cerca un User tramite il suo attributo email, e genera l’implementazione a runtime. Questo elimina quasi completamente la necessità di scrivere codice per il data access layer.

Il Persistence Context:

Il Persistence Context è un concetto fondamentale di JPA (e quindi di Hibernate e Spring Data JPA).

  • Definizione: Il Persistence Context può essere visto come una cache di primo livello o un “ambiente di lavoro” che si trova tra l’applicazione e il database. È gestito dall’EntityManager di JPA.
  • Funzionamento:
    1. Quando si carica un’entità dal database (es. con findById()), JPA non restituisce solo l’oggetto, ma lo inserisce anche nel Persistence Context.
    2. Se si richiede di nuovo la stessa entità all’interno della stessa transazione, JPA la restituirà direttamente dal Persistence Context (dalla cache) senza interrogare nuovamente il database.
    3. Dirty Checking: Qualsiasi modifica apportata a un’entità “gestita” (managed), cioè presente nel Persistence Context, viene tracciata automaticamente.
    4. Commit della Transazione: Alla fine della transazione (tipicamente alla fine di un metodo @Service annotato con @Transactional), JPA confronta lo stato attuale delle entità gestite con il loro stato originale. Se rileva delle modifiche (dirty checking), genera e invia automaticamente le query UPDATE al database.

Il Persistence Context è il meccanismo che permette a JPA di gestire lo stato delle entità in modo efficiente, ottimizzare le query e garantire la coerenza dei dati all’interno di una transazione.

Relazioni database con teoria ER, fetch strategies, N+1 problem

Teoria ER e Mappatura in JPA:

Il modello Entità-Relazione (ER) è un modo per progettare database relazionali. Le sue controparti in JPA sono le annotazioni di relazione:

  • One-to-One (Uno-a-Uno): Un’istanza di un’entità è associata a una e una sola istanza di un’altra entità.

    • Esempio: Un User ha un UserProfile.
    • Annotazioni JPA: @OneToOne.
  • One-to-Many (Uno-a-Molti): Un’istanza di un’entità (il “lato uno”) può essere associata a molte istanze di un’altra entità (il “lato molti”).

    • Esempio: Un Author ha molti Book.
    • Annotazioni JPA: @OneToMany sul lato Author e @ManyToOne sul lato Book. La relazione bidirezionale è la più comune.
  • Many-to-Many (Molti-a-Molti): Molte istanze di un’entità possono essere associate a molte istanze di un’altra.

    • Esempio: Un Student può essere iscritto a molti Course, e un Course può avere molti Student.
    • Implementazione Relazionale: Richiede una tabella di giunzione intermedia (es. student_course) che contiene le chiavi esterne di entrambe le tabelle.
    • Annotazione JPA: @ManyToMany.

Fetch Strategies (Strategie di Recupero):

Quando JPA carica un’entità dal database, cosa dovrebbe fare con le sue entità correlate? La Fetch Strategy definisce questo comportamento.

  1. FetchType.EAGER (Caricamento “Avido”):

    • Comportamento: Quando si carica l’entità principale (es. un Author), JPA carica immediatamente anche tutte le sue entità correlate (tutti i suoi Book).
    • Come funziona: Tipicamente, JPA esegue una singola query SQL utilizzando un JOIN (o query multiple, a seconda della configurazione) per caricare tutto in una volta.
    • Default JPA:
      • @OneToOne e @ManyToOne sono EAGER di default.
    • Pro: L’associazione è sempre disponibile. Nessun errore LazyInitializationException.
    • Contro: Può essere molto inefficiente. Si potrebbero caricare grandi grafi di oggetti dalla memoria quando serve solo l’entità principale, sprecando memoria e tempo.
  2. FetchType.LAZY (Caricamento “Pigro”):

    • Comportamento: Quando si carica l’entità principale (es. un Author), le sue entità correlate (i Book) non vengono caricate. Al loro posto, JPA inserisce un proxy, un oggetto “segnaposto”.
    • Come funziona: I dati della collezione correlata verranno caricati dal database solo nel momento in cui si accede per la prima volta a quella collezione (es. author.getBooks().size()).
    • Default JPA:
      • @OneToMany e @ManyToMany sono LAZY di default.
    • Pro: Molto più efficiente. Si caricano solo i dati effettivamente necessari.
    • Contro: Se si cerca di accedere a una collezione LAZY al di fuori di una transazione attiva (cioè quando il Persistence Context è chiuso), si otterrà una LazyInitializationException.

Best Practice: Preferire sempre LAZY per le collezioni (@OneToMany, @ManyToMany) e spesso anche per le relazioni singole, e caricare esplicitamente i dati necessari quando servono (usando JOIN FETCH nelle query JPQL o EntityGraph).

Il Problema N+1 Select:

Questo è uno dei problemi di performance più comuni quando si usa un ORM.

  • Scenario: Supponiamo di avere una relazione LAZY @OneToMany tra Author e Book. Vogliamo recuperare tutti gli autori e stampare il titolo del loro primo libro.

  • Codice ingenuo:

    List<Author> authors = authorRepository.findAll(); // 1 query per trovare tutti gli autori
    for (Author author : authors) {
        System.out.println(author.getBooks().get(0).getTitle()); // N query, una per ogni autore, per inizializzare la sua lista di libri
    }
  • Il Problema:

    1. Viene eseguita una query per selezionare tutti gli autori.
    2. Poi, all’interno del ciclo, per ogni autore (N autori), quando si accede a .getBooks(), JPA esegue una nuova query per caricare i libri di quell’autore specifico.
    3. Il risultato è 1 + N query al database, che è estremamente inefficiente.

Soluzioni al Problema N+1:

  1. JOIN FETCH in JPQL: È la soluzione più comune ed efficace. Si scrive una query personalizzata che dice a JPA di caricare gli autori e le loro collezioni di libri in un’unica query SQL usando un JOIN.

    @Query("SELECT a FROM Author a JOIN FETCH a.books")
    List<Author> findAllWithBooks();

    Questa singola query recupera tutti i dati necessari in un colpo solo.

  2. @EntityGraph: Un’alternativa più moderna e a volte più pulita a JOIN FETCH. Permette di definire, tramite annotazioni, quali associazioni devono essere caricate “avidamente” per una specifica operazione di query, senza dover scrivere la JPQL a mano.

  3. Batch Fetching: Una configurazione a livello di Hibernate (@BatchSize) che, invece di eseguire una query per ogni autore, ne esegue una per un “lotto” di autori (es. WHERE author_id IN (?, ?, ?, ...)), riducendo significativamente il numero di round trip al database.

Esercizi Spring Data JPA & N+1 Problem

Difficoltà crescente: dalla creazione di un’entità base alla diagnosi e risoluzione del problema N+1.

  1. Esercizio 1 (Base): Entità e Repository
    • Configura un database in-memory H2. Crea un’entità JPA Prodotto (@Entity) con id e nome. Crea un’interfaccia ProdottoRepository che estende JpaRepository<Prodotto, Long>. Scrivi un @CommandLineRunner per salvare alcuni prodotti all’avvio e poi stamparli leggendoli dal repository.
  2. Esercizio 2 (Facile): Query Derivate
    • Aggiungi un attributo categoria all’entità Prodotto. Aggiungi un metodo List<Prodotto> findByCategoria(String categoria); all’interfaccia ProdottoRepository. Spring Data JPA implementerà la query automaticamente. Scrivi un test per verificare che funzioni.
  3. Esercizio 3 (Medio-Facile): Query Personalizzata con @Query
    • Aggiungi un attributo prezzo all’entità. Scrivi un metodo nel repository per trovare tutti i prodotti il cui prezzo è superiore a un valore dato. Implementalo usando l’annotazione @Query con una query JPQL (es. SELECT p FROM Prodotto p WHERE p.prezzo > :prezzoMinimo).
  4. Esercizio 4 (Medio): Relazione One-to-Many
    • Crea una nuova entità Produttore e stabilisci una relazione @OneToMany da Produttore a Prodotto. La relazione inversa (@ManyToOne da Prodotto a Produttore) deve essere FetchType.LAZY. Scrivi un test per creare un produttore e associargli diversi prodotti.
  5. Esercizio 5 (Medio-Difficile): Rilevare il Problema N+1
    • Abilita la visualizzazione delle query SQL in application.properties (spring.jpa.show-sql=true). Scrivi un metodo di servizio che:
      1. Recupera tutti i Produttore dal database (1 query).
      2. Itera su ogni produttore e stampa il nome del suo primo prodotto (produttore.getProdotti().get(0).getNome()).
    • Osserva la console: vedrai una query iniziale per i produttori, seguita da N query separate per recuperare i prodotti di ogni produttore. Hai appena riprodotto il problema N+1.
  6. Esercizio 6 (Difficile): Risolvere il Problema N+1
    • Risolvi il problema dell’esercizio 5 in due modi diversi nel ProduttoreRepository:
      1. JOIN FETCH: Crea un metodo con una @Query JPQL che usa JOIN FETCH per caricare i produttori e i loro prodotti in una singola query (SELECT p FROM Produttore p JOIN FETCH p.prodotti).
      2. Entity Graph: Crea un metodo findAll e annotalo con @EntityGraph(attributePaths = "prodotti") per specificare di caricare la collezione di prodotti in modo EAGER per quella specifica query.
    • Verifica per entrambi i casi che il numero di query eseguite si sia ridotto drasticamente.

Conclusioni

Questo articolo ha coperto i concetti fondamentali di Java e Spring Boot, dalle basi della programmazione orientata agli oggetti fino alle architetture moderne per lo sviluppo di applicazioni enterprise.

Punti chiave appresi:

  • Java Fundamentals: JVM, tipi di dato, principi OOP (incapsulamento, ereditarietà, polimorfismo), interfacce e principi SOLID
  • Collections e Stream API: ArrayList, HashMap, programmazione funzionale e ottimizzazioni
  • Gestione Errori: Eccezioni, stack unwinding e best practices
  • Spring Boot: Dependency Injection, architetture a layer, REST API e persistence con JPA

Questi concetti costituiscono la base per lo sviluppo di applicazioni Java robuste e scalabili. La comprensione profonda di questi principi ti permetterà di affrontare con successo progetti di complessità crescente nel mondo dello sviluppo enterprise.