Indice
Fondamenti Java
- La storia di Java
- Il cuore di Java, la JVM
- I tipi di dato in Java
- Storia dell’OOP e differenze con il paradigma procedurale
- Incapsulamento e information hiding
- Ereditarietà
- Polimorfismo
- Interfacce e design patterns
- Principi SOLID
Collections e API Stream
Gestione degli Errori
Framework Spring Boot
- Storia e principi di Spring Boot
- Dependency Injection e IoC
- Annotazioni e architettura a layer
- REST API e principi architetturali
- Codici HTTP e design patterns
- Spring Data JPA
- Relazioni database e ottimizzazioni
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:
- Scrittura del Codice Sorgente: Lo sviluppatore scrive il codice in file con estensione
.java
. - 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. - 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:
-
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.
- Loading: Legge i file
-
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++).
-
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:
-
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.
-
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.
- Il comando
- Nello stesso terminale, eseguire il comando:
Esercizio:
- Modificare il file
HelloWorld.java
per stampare un messaggio diverso. - Ricompilare il file.
- Eseguire nuovamente il programma e verificare che l’output sia cambiato.
- Provare a eseguire il programma senza ricompilarlo dopo una modifica. Cosa succede? Perché?
- Introdurre un errore di sintassi nel file
.java
(es. omettendo un punto e virgola) e provare a compilarlo. Osservare l’errore restituito dal compilatorejavac
.
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 unint
) senza un “cast” esplicito da parte del programmatore.
In Java, i tipi di dati si dividono in due categorie principali:
-
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.
-
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 metodotryToModify
riceve una copia del valore50
. La modificacash = 1000
avviene solo su questa copia locale. La variabile originalemyCash
nelmain
non viene toccata. - Nel secondo caso (
myWallet
), il metodotryToModify
riceve una copia del riferimento all’oggettoWallet
. Entrambi i riferimenti (quello nelmain
e quello nel metodo) puntano allo stesso oggetto in memoria. Quindi, quando il metodo invocawallet.addMoney(100)
, sta modificando lo stato dell’unico oggetto esistente, e la modifica è visibile anche dalmain
.
Esercizi Tipi Primitivi vs. Tipi Riferimento
Difficoltà crescente: dalla sintassi base alla comprensione profonda della memoria e degli oggetti immutabili.
- Esercizio 1 (Base): Dichiarazione e Assegnazione
- Scrivi un programma che dichiari e inizializzi una variabile primitiva
int
chiamataeta
e una variabile di riferimentoString
chiamatanome
. Stampale a video.- Esercizio 2 (Facile): Passaggio per Valore con Primitivi
- Crea un metodo
incrementa(int numero)
che prende un intero, gli aggiunge 10 e lo stampa. Nelmain
, dichiara un intero, passalo a questo metodo e poi stampalo di nuovo nelmain
. Osserva e spiega perché il valore originale non è cambiato.- Esercizio 3 (Medio-Facile): Modifica dello Stato di un Oggetto
- Usa la classe
StringBuilder
. Crea un metodoaggiungiTesto(StringBuilder builder)
che appende la stringa ” Mondo!” alStringBuilder
passato come parametro. Nelmain
, crea unoStringBuilder
con “Ciao”, passalo al metodo e poi stampalo di nuovo. Osserva e spiega perché questa volta la modifica è visibile.- Esercizio 4 (Medio): Riassegnazione del Riferimento
- Crea una classe semplice
Punto
con due attributiint x, y
. Scrivi un metodoriassegna(Punto p)
che crea un nuovo punto (p = new Punto(100, 100);
). Nelmain
, crea unPunto
, stampalo, passalo al metodoriassegna
e stampalo di nuovo. Spiega perché l’oggetto originale nelmain
non è cambiato.- Esercizio 5 (Medio-Difficile): Manipolazione di Array
- Crea un metodo
modificaArray(int[] array)
che imposta il primo elemento dell’array a99
. Crea un secondo metodoriassegnaArray(int[] array)
che crea un nuovo array (array = new int[]{0, 0, 0};
). Nelmain
, crea un array{1, 2, 3}
, passalo prima amodificaArray
e stampalo, poi passalo ariassegnaArray
e stampalo di nuovo. Spiega i due risultati diversi.- Esercizio 6 (Difficile): Wrapper Classes e Immutabilità
- Crea un metodo
modificaWrapper(Integer numero)
che cerca di cambiare il valore del wrapper (numero = 20;
). Nelmain
, crea una variabileInteger 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 diStringBuilder
(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 comecolore
,marca
,velocitàMassima
) e i comportamenti (metodi comeaccelera()
,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 messaggioaccelera()
all’oggettoAutomobile
.
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.
Aspetto | Programmazione Procedurale | Programmazione Orientata agli Oggetti |
---|---|---|
Focus Principale | Sulle procedure e algoritmi. Il programma è una sequenza di passi. | Sugli oggetti e sui dati. Il programma è un’interazione tra oggetti. |
Organizzazione | Diviso in funzioni. | Diviso in classi e oggetti. |
Dati vs. Funzioni | I 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). |
Approccio | Top-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 Dati | Meno sicura. I dati globali possono essere modificati da qualsiasi funzione. | Più sicura grazie all’information hiding. Lo stato interno di un oggetto è protetto. |
Riutilizzo Codice | Limitato 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:
- 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.
- 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
esetter
) 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 metodowithdraw()
(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.
- Esercizio 1 (Base): La Classe “Anemica”
- Crea una classe
ContoCorrente
con due attributipublic String titolare;
epublic double saldo;
. Nelmain
, crea un’istanza e imposta un saldo negativo direttamente (conto.saldo = -500;
). Spiega perché questo è un problema.- Esercizio 2 (Facile): Getters e Setters
- Modifica la classe
ContoCorrente
dell’esercizio 1. Rendi gli attributiprivate
e aggiungi metodipublic
getTitolare()
,setTitolare()
,getSaldo()
esetSaldo()
.- Esercizio 3 (Medio-Facile): Logica di Validazione
- Migliora l’esercizio 2. All’interno del metodo
setSaldo(double nuovoSaldo)
, aggiungi un controllo per assicurarti chenuovoSaldo
non sia negativo. Se lo è, stampa un messaggio di errore e non modificare il saldo. Fai lo stesso per i metodideposita(double importo)
epreleva(double importo)
.- Esercizio 4 (Medio): Campi Read-Only
- Aggiungi alla classe
ContoCorrente
un attributoprivate 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.- Esercizio 5 (Medio-Difficile): Incapsulamento di Collezioni
- Aggiungi un attributo
private List<String> listaMovimenti
alla classeContoCorrente
. Nel metodogetListaMovimenti()
, 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)
).- Esercizio 6 (Difficile): Incapsulamento di Oggetti Interni Mutabili
- Aggiungi un attributo
private Date dataApertura
(usajava.util.Date
che è mutabile). Se il metodogetDataApertura()
restituiscethis.dataApertura
, il codice esterno può fareconto.getDataApertura().setTime(0);
e modificare lo stato interno del tuo oggetto. Risolvi il problema restituendo una copia dell’oggettoDate
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:
- Riutilizzo del Codice: È il vantaggio più evidente. Il codice comune viene scritto una sola volta nella superclasse e riutilizzato da tutte le sottoclassi.
- Organizzazione Logica: Crea gerarchie chiare e comprensibili che modellano le relazioni del dominio del problema.
- 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:
- 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.
- 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.
- 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.
- 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
.
- Esercizio 1 (Base): Superclasse e Sottoclasse
- Crea una classe
Personaggio
con attributinome
epuntiVita
. Crea una sottoclasseGuerriero
cheextends Personaggio
e aggiunge un attributoarma
. Nelmain
, crea unGuerriero
e accedi sia agli attributi ereditati che a quello specifico.- Esercizio 2 (Facile): Costruttori e
super()
- Aggiungi un costruttore alla classe
Personaggio
che inizializzanome
epuntiVita
. Il compilatore ora segnalerà un errore inGuerriero
. Correggilo creando un costruttore inGuerriero
che accettanome
,puntiVita
earma
, e che usasuper(nome, puntiVita);
per chiamare il costruttore della superclasse.- Esercizio 3 (Medio-Facile): Override di Metodi
- Aggiungi un metodo
descrivi()
aPersonaggio
che stampa le informazioni base. Fai l’override (@Override
) del metododescrivi()
inGuerriero
in modo che stampi anche l’arma. Per non riscrivere codice, la versione delGuerriero
deve prima chiamaresuper.descrivi();
.- Esercizio 4 (Medio): Modificatore
protected
- Cambia la visibilità di
puntiVita
inPersonaggio
daprivate
aprotected
. Crea un metodosubisciDanno(int danno)
inGuerriero
che modifica direttamentethis.puntiVita
. Dimostra che funziona. Ora prova ad accedere apuntiVita
da una classe non correlata (es. dalmain
) e osserva l’errore.- Esercizio 5 (Medio-Difficile): Gerarchia a Tre Livelli
- Crea una nuova classe
Paladino
cheextends Guerriero
. Aggiungi un attributofede
. Fai l’override del metododescrivi()
anche inPaladino
, assicurandoti che chiamisuper.descrivi()
per riutilizzare la logica diGuerriero
(che a sua volta riutilizza quella diPersonaggio
).- Esercizio 6 (Difficile): Classi e Metodi
final
- Crea una classe
Ladro
. Rendifinal
il suo metodoscassina()
. Prova a creare una sottoclasseAssassino
che fa l’override discassina()
e osserva l’errore del compilatore. Successivamente, rendi l’intera classeLadro
final
. Prova a far estendereAssassino
daLadro
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
efinal
, 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
oGatto
). - La JVM cerca l’indirizzo del metodo
emettiSuono()
all’interno della VMT. Questo indirizzo sarà quello del metodo specifico della classeCane
oGatto
. - 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.
- Esercizio 1 (Base): Array Polimorfico
- Crea una classe astratta
StrumentoMusicale
con un metodo astrattosuona()
. Crea due sottoclassi concreteChitarra
ePianoforte
che implementanosuona()
. Nelmain
, crea un array di tipoStrumentoMusicale[]
e inserisci al suo interno un’istanza diChitarra
e una diPianoforte
.- Esercizio 2 (Facile): Chiamate Polimorfiche
- Usando l’array dell’esercizio 1, scrivi un ciclo
for-each
che itera su ogniStrumentoMusicale
e chiama il metodosuona()
. Osserva come viene eseguito il metodo corretto per ogni oggetto.- Esercizio 3 (Medio-Facile): Polimorfismo nei Parametri
- Crea un metodo statico
accordaStrumento(StrumentoMusicale strumento)
che stampa “Accordando lo strumento…” e poi chiamastrumento.suona()
. Nelmain
, passa a questo metodo sia l’oggettoChitarra
che l’oggettoPianoforte
e verifica il funzionamento.- Esercizio 4 (Medio):
instanceof
e Downcasting
- Aggiungi un metodo specifico solo alla classe
Chitarra
chiamatocambiaCorde()
. Nel ciclofor-each
dell’esercizio 2, aggiungi un controllo:if (strumento instanceof Chitarra)
, e se è vero, esegui un downcasting (Chitarra chitarra = (Chitarra) strumento;
) e chiama il metodochitarra.cambiaCorde()
.- Esercizio 5 (Medio-Difficile): Polimorfismo con Interfacce
- Crea un’interfaccia
Elettrico
con un metodocollegaAllaCorrente()
. Fai in modo che la classeChitarra
implementi questa interfaccia, maPianoforte
no. Nel ciclo, aggiungi un controlloif (strumento instanceof Elettrico)
e, in caso affermativo, esegui il cast all’interfaccia e chiama il metodo specifico.- Esercizio 6 (Difficile): Evitare
if-else
coninstanceof
(Visitor Pattern Semplificato)
- Questo esercizio introduce un’alternativa più elegante al downcasting. Aggiungi un metodo
accetta(Visitor v)
aStrumentoMusicale
. Crea un’interfacciaVisitor
con metodivisit(Chitarra c)
evisit(Pianoforte p)
. InChitarra
, l’implementazione diaccetta
saràv.visit(this);
. Ora, invece diif-else
, puoi creare una classeManutentoreVisitor
che implementaVisitor
e contiene la logica specifica per ogni strumento. Il ciclo nelmain
diventerà semplicementestrumento.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 metodoupdate()
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).
- Esercizio 1 (Base): Implementare un’Interfaccia
- Crea un’interfaccia
Volante
con un metodovola()
. Crea due classi,Uccello
eAereo
, che implementano questa interfaccia. Ogni implementazione divola()
stamperà un messaggio diverso.- Esercizio 2 (Facile): Polimorfismo con Interfacce
- Nel
main
, crea unaList<Volante>
. Aggiungi un’istanza diUccello
e una diAereo
. Scrivi un ciclo che itera sulla lista e chiama il metodovola()
per ogni elemento.- Esercizio 3 (Medio-Facile): Interfacce Multiple
- Crea una seconda interfaccia
Nuotante
con un metodonuota()
. Crea una classeAnatra
cheimplements Volante, Nuotante
. Nelmain
, dimostra che un oggettoAnatra
può essere inserito sia in unaList<Volante>
che in unaList<Nuotante>
.- Esercizio 4 (Medio): Metodi
default
- Aggiungi un metodo
default
all’interfacciaVolante
chiamatoatterra()
, che stampa “Atterraggio standard.”. Dimostra che le classiUccello
eAereo
“ereditano” questo metodo senza bisogno di modifiche. Poi, fai l’override diatterra()
nella classeAereo
per fornire un’implementazione più specifica.- Esercizio 5 (Medio-Difficile): Interfacce per il Disaccoppiamento
- Crea un’interfaccia
Logger
con un metodolog(String messaggio)
. Crea due implementazioni:ConsoleLogger
(stampa su console) eFileLogger
(scrive su un file fittizio). Crea una classeCalcolatrice
che nel costruttore accetta unLogger
. Ogni volta che laCalcolatrice
esegue un’operazione, usa il logger per registrare l’evento. Nelmain
, mostra come puoi passare alla stessaCalcolatrice
prima unConsoleLogger
e poi unFileLogger
senza cambiare una riga di codice dellaCalcolatrice
.- Esercizio 6 (Difficile): Strategy Pattern
- Crea un’interfaccia
StrategiaDiPrezzo
con un metodocalcolaPrezzo(double prezzoBase)
. Crea due implementazioni:PrezzoStandard
(restituisceprezzoBase
) ePrezzoScontato
(restituisceprezzoBase * 0.8
). Crea una classeCarrello
che ha un attributoStrategiaDiPrezzo
. Aggiungi un metodosetStrategia(StrategiaDiPrezzo s)
e un metodogetPrezzoFinale(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
oswitch
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 qualsiasiSottoclasse
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:
- “I moduli di alto livello non dovrebbero dipendere da moduli di basso livello. Entrambi dovrebbero dipendere da astrazioni.”
- “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’elementoi
può essere calcolato direttamente, risultando in una complessità temporale di O(1) (tempo costante). - Aggiunta (add):
- 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). - 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.
- Caso Migliore: Se c’è ancora spazio nell’array interno, l’elemento viene semplicemente aggiunto alla fine, e un contatore
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.
- Esercizio 1 (Base): Operazioni CRUD
- Crea un
ArrayList
diString
. 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.
- Esercizio 2 (Facile): Iterazione
- Crea un
ArrayList
diInteger
e riempilo con 5 numeri. Scrivi tre cicli diversi per stampare tutti gli elementi: un ciclofor
classico con indice, un ciclofor-each
e un’iterazione usandoforEach
con una lambda expression (lista.forEach(n -> System.out.println(n));
).- 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 è, usaindexOf()
per trovare la sua posizione e stamparla.- Esercizio 4 (Medio): Performance di Inserimento
- Crea un
ArrayList
e unLinkedList
diInteger
. 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 usandoSystem.nanoTime()
prima e dopo il ciclo. Spiega la drastica differenza di performance.- Esercizio 5 (Medio-Difficile): Rimozione durante l’Iterazione (Errore Comune)
- Crea un
ArrayList
diInteger
da 1 a 10. Prova a rimuovere tutti i numeri pari usando un ciclofor-each
. Osserva laConcurrentModificationException
. Spiega perché accade.- 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 metodoiterator.remove()
per rimuoverlo in modo sicuro. In alternativa, mostra come risolvere il problema usando il metodoremoveIf
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:
- Hashing: Quando si inserisce una coppia (
K
,V
),HashMap
prende la chiaveK
e calcola un valore intero chiamato hash code tramite il metodohashCode()
della chiave. Questo hash code viene poi elaborato da una funzione di hashing interna per determinare un indice in un array interno (chiamatotable
obuckets
). - Array di Bucket: L’array interno è una serie di “secchielli” (bucket). L’indice calcolato determina in quale bucket la coppia chiave-valore verrà memorizzata.
- 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):
- Struttura del Bucket: Ogni bucket dell’array non contiene un singolo elemento, ma la testa di una lista concatenata (LinkedList) di nodi.
- 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.
- 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 metodoequals()
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:
- Se due oggetti sono uguali secondo
equals()
, devono restituire lo stessohashCode()
. - Se due oggetti hanno lo stesso
hashCode()
, non è detto che siano uguali secondoequals()
(questa è una collisione). Se questo contratto non viene rispettato, laHashMap
avrà un comportamento imprevedibile.
Esercizi HashMap
Difficoltà crescente: dall’uso base alla creazione di una classe personalizzata come chiave, comprendendo il contratto
hashCode
/equals
.
- 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.
- 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.- Esercizio 3 (Medio-Facile): Calcolo delle Frequenze
- Scrivi un programma che prende un testo (una
String
) e usa unaHashMap<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.- Esercizio 4 (Medio): Gestire Valori Mancanti
- Migliora l’esercizio 3 usando il metodo
getOrDefault
. Invece di controllare concontainsKey
, puoi scriveremappa.put(carattere, mappa.getOrDefault(carattere, 0) + 1);
. Spiega come questa singola riga semplifica il codice.- Esercizio 5 (Medio-Difficile): Chiave Personalizzata (Senza
hashCode
/equals
)
- Crea una classe
Studente
conid
enome
. Crea unaHashMap<Studente, Integer>
per mappare studenti a voti. Crea due oggettiStudente
distinti ma con gli stessi dati (new Studente(1, "Mario")
enew 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
restituiscenull
). Spiega perché.- Esercizio 6 (Difficile): Il Contratto
hashCode
/equals
- Risolvi l’esercizio 5. Fai l’override dei metodi
hashCode()
eequals()
nella classeStudente
. La logica diequals
dovrebbe confrontare gliid
. La logica dihashCode
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.
- Imperativo: Si descrive il “come” fare qualcosa, passo dopo passo (es. un ciclo
Caratteristiche Principali di uno Stream:
- 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()
.
- Operazioni Intermedie (Intermediate Operations): Trasformano uno stream in un altro stream. Esempi:
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:
- 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). - 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” (comelimit()
).
- Efficienza: Si eseguono solo i calcoli strettamente necessari. Se un’operazione come
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.
- Esercizio 1 (Base):
filter
eforEach
- Data una
List<String>
di nomi, usa uno stream per filtrare solo i nomi che iniziano con la lettera “A” e stamparli a video.- Esercizio 2 (Facile):
map
ecollect
- 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à esserecollect(Collectors.toList())
.- Esercizio 3 (Medio-Facile): Chaining (Pipeline)
- Data una
List<Prodotto>
(con attributinome
,categoria
,prezzo
), scrivi un singolo pipeline di stream che:
- Filtra i prodotti della categoria “Elettronica”.
- Filtra quelli con un prezzo superiore a 100.
- Estrae (mappa) solo i nomi dei prodotti.
- Raccoglie i nomi in una nuova lista.
- Esercizio 4 (Medio): Operazioni Terminali diverse (
findFirst
,anyMatch
)
- Data una
List<Integer>
, usa uno stream per:
- Verificare se esiste almeno un numero maggiore di 50 (
anyMatch
).- Trovare il primo numero pari e stamparlo (
filter
efindFirst
). Gestisci il caso in cui non esista conOptional
.- Esercizio 5 (Medio-Difficile):
reduce
emapToInt
- Data una
List<Integer>
, calcola la somma di tutti i numeri in due modi diversi usando gli stream:
- Usando il metodo
reduce
.- Usando
mapToInt
per convertire loStream<Integer>
in unIntStream
e poi chiamando il metodosum()
, che è più efficiente.- Esercizio 6 (Difficile): Raggruppamento con
Collectors.groupingBy
- Data la
List<Prodotto>
dell’esercizio 3, usa uno stream e uncollector
per creare unaMap<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:
- 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 diif-else
. Questo mescolava la logica di business con la gestione degli errori, rendendo il codice difficile da leggere e manutenere. - 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 blocchicatch
. - 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.
- 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). - Ricerca di un Handler: Il runtime cerca, all’interno del metodo corrente, un blocco
catch
che possa gestire quel tipo di eccezione. - 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.
- Se non viene trovato un
- Gestione o Terminazione:
- Se viene trovato un blocco
catch
compatibile, lo stack smette di srotolarsi e il codice all’interno del bloccocatch
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.
- Se viene trovato un blocco
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 bloccotry
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:
- Creazione dell’Oggetto Eccezione: Richiede l’allocazione di memoria sull’heap.
- Cattura dello Stack Trace: Questa è l’operazione più costosa. La JVM deve percorrere l’intero stack di chiamate per costruire la stack trace.
- 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
.
- Esercizio 1 (Base):
try-catch
- Scrivi un programma che divide due numeri. Metti l’operazione di divisione in un blocco
try
e cattura laArithmeticException
che si verifica quando si tenta di dividere per zero, stampando un messaggio di errore appropriato.- 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 bloccofinally
viene eseguito in entrambi i casi.- 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 laArithmeticException
con due blocchicatch
distinti.- Esercizio 4 (Medio): Checked vs. Unchecked Exceptions
- Scrivi un metodo
leggiFile(String nomeFile)
che potrebbe lanciare unaIOException
(che è una checked exception). Non gestirla all’interno del metodo, ma aggiungithrows IOException
alla firma del metodo. Chiama questo metodo dalmain
e osserva che il compilatore ti costringe a gestire l’eccezione con untry-catch
o a propagarla ulteriormente.- Esercizio 5 (Medio-Difficile): Eccezioni Personalizzate
- Crea una tua classe di eccezione personalizzata (checked)
SaldoInsufficienteException
che estendeException
. Modifica la classeContoCorrente
(da un esercizio precedente) in modo che il metodopreleva(double importo)
lanci questa eccezione se il saldo non è sufficiente, invece di stampare solo un messaggio.- Esercizio 6 (Difficile):
try-with-resources
- Scrivi un programma che apre un file per la lettura usando
BufferedReader
eFileReader
. Invece di usare un bloccofinally
per assicurarti che il reader venga chiuso (reader.close()
), usa il costruttotry-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
, ilViewResolver
, iDataSource
,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:
-
“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, ilDispatcherServlet
e altre componenti essenziali senza che lo sviluppatore debba scrivere una sola riga di configurazione.
- Esempio: Se trova la dipendenza
-
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.). -
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. -
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. -
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.
- 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.- Esercizio 2 (Facile): Utilizzo delle Proprietà
- Nel file
application.properties
, cambia la porta del server embedded a8090
usando la proprietàserver.port
. Riavvia l’applicazione e verifica che ora risponda sulla nuova porta.- Esercizio 3 (Medio-Facile): Aggiungere un Altro Starter
- Aggiungi lo starter
spring-boot-starter-actuator
al tuopom.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.- Esercizio 4 (Medio): Creare una Classe di Configurazione Personalizzata
- Crea una classe
AppInfo
con due campi stringa,name
eversion
. Annotala con@ConfigurationProperties(prefix = "app")
e@Configuration
. Abilitala con@EnableConfigurationProperties
sulla classe principale. Definisci le proprietàapp.name
eapp.version
inapplication.properties
.- 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.- Esercizio 6 (Difficile): Analizzare l’Auto-Configurazione
- Avvia l’applicazione con il flag
--debug
(o impostadebug=true
inapplication.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 esempioDataSourceAutoConfiguration
.
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:
-
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.
- Scansione dei Componenti: Il container scansiona il classpath alla ricerca di classi annotate con stereotipi come
-
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.
- Creazione dei Bean: Quando un bean è richiesto (o all’avvio, nel caso dei singleton), il container usa la
Tipi di Iniezione in Spring:
Spring offre tre modi principali per iniettare le dipendenze.
-
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.
- Immutabilità: Le dipendenze possono essere dichiarate
-
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.
-
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
.
- Come funziona: L’annotazione
Esercizi Dependency Injection & IoC
Difficoltà crescente: dall’iniezione base alla gestione di ambiguità e scope dei bean.
- Esercizio 1 (Base): Field Injection
- Crea una classe
MessageService
annotata con@Service
che ha un metodogetMessage()
che restituisce “Messaggio di servizio”. Crea unMessageController
annotato con@RestController
e inietta ilMessageService
usando@Autowired
direttamente sul campo. Crea un endpoint che chiamigetMessage()
e restituisca il risultato.- Esercizio 2 (Facile): Constructor Injection
- Riscrivi l’esercizio 1 utilizzando la constructor injection, che è la pratica raccomandata. Rendi il campo
MessageService
nel controllerfinal
. Spiega i vantaggi di questo approccio (immutabilità, dipendenze esplicite).- Esercizio 3 (Medio-Facile): Iniezione di Interfacce
- Crea un’interfaccia
GreetingService
. Crea una classeGreetingServiceImpl
che implementa l’interfaccia. Nel controller, inietta l’interfacciaGreetingService
, non l’implementazione concreta. Questo dimostra il principio di “programmare verso un’interfaccia”.- Esercizio 4 (Medio): Gestire l’Ambiguità con
@Qualifier
- Crea una seconda implementazione dell’interfaccia
GreetingService
chiamataFormalGreetingServiceImpl
(es. una restituisce “Ciao!” e l’altra “Buongiorno.”). Annotale entrambe con@Service
. Avvia l’applicazione e osserva l’erroreNoUniqueBeanDefinitionException
. 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.- Esercizio 5 (Medio-Difficile): Bean Scopes
- Crea un
HitCounterService
con un contatore interno (private int count = 0;
) e un metodoincrementAndGet()
. Annotalo con@Service
e@Scope("session")
. Inietta questo servizio in un controller e crea un endpoint che chiamaincrementAndGet()
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.- 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 è:
-
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.
-
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.
-
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 HTTP
→ RestController
(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.
- Esercizio 1 (Base): Il Presentation Layer
- Crea un’applicazione per gestire una lista di “Task”. Crea un
TaskController
(@RestController
) con un metodoGET /tasks
che restituisce una lista hardcoded di stringhe (es. “Studiare Spring”, “Fare la spesa”).- 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 ilTaskService
e chiamare un suo metodo (es.findAll()
) per ottenere i dati.- Esercizio 3 (Medio-Facile): Aggiungere il Repository Layer (Mock)
- Crea una classe
TaskRepository
(@Repository
). Simula un database usando unaMap<Long, String>
interna. Il repository deve avere metodi comefindAll()
,findById(Long id)
,save(String task)
. IlTaskService
ora deve usare ilTaskRepository
per gestire i dati.- Esercizio 4 (Medio): Definire un Modello e un DTO
- Crea una classe modello
Task
(un POJO) conid
,description
,completed
. Il repository ora lavorerà con oggettiTask
. Crea anche una classeTaskDTO
che espone solo i campi che vuoi mostrare al client (es. omettendo l’id).- Esercizio 5 (Medio-Difficile): Collegare tutti i Layer con DTO
- Implementa il flusso completo:
- Il
TaskController
riceve/restituisceTaskDTO
.- Il
TaskService
riceve/restituisceTaskDTO
, ma internamente li converte in entitàTask
per interagire con il repository.- Il
TaskRepository
lavora esclusivamente con le entitàTask
.- Implementa un metodo
POST /tasks
che accetta unTaskDTO
per creare un nuovo task.- 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 dicom.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:
- 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.
- 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à.
- 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.
- 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.
- Identificazione delle Risorse: Ogni “cosa” (un utente, un prodotto, un ordine) è una risorsa identificata da un URI univoco (es.
- 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.
- 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.
- 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 degliif/else
per decidere quale logica eseguire.- 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 ePOST /api/books/1
per ottenere un libro specifico. Stai ancora usando solo il verboPOST
.- 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
eGET /api/books/{id}
. Usa l’annotazione@PathVariable
per recuperare l’id dall’URL.- 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).- 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.- Esercizio 6 (Difficile - RMM Level 3): HATEOAS
- Aggiungi lo starter
spring-boot-starter-hateoas
. Modifica il DTO del libro in modo che estendaRepresentationModel
. Modifica il metodoGET /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 perGET
ePUT
/PATCH
riuscite.201 Created
: La richiesta è stata soddisfatta e ha portato alla creazione di una nuova risorsa. La risposta dovrebbe includere un headerLocation
con l’URI della nuova risorsa. Usato perPOST
.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 perDELETE
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 richiestaGET
condizionale (es. con headerIf-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. unPUT
su un URI che accetta soloGET
).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:
-
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
esize
(olimit
eoffset
). - 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.
- Pattern: Usare parametri di query come
-
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) oGET /products?sort=-price
(ordina per prezzo, discendente).
- Pattern: Usare un parametro di query come
-
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
.
-
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
.
- Pattern: Usare un parametro di query come
-
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’headerAccept
.
-
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.
- Esercizio 1 (Base): Codici di Successo Corretti
- Rivedi l’API CRUD dell’esercizio precedente. Assicurati che:
POST
restituisca201 Created
e l’headerLocation
con l’URL della nuova risorsa. UsaResponseEntity
per costruire questa risposta.DELETE
restituisca204 No Content
.GET
ePUT
restituiscano200 OK
.- 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 unaResponseEntity
con status404 Not Found
.- 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 laResourceNotFoundException
e restituisce una risposta404
con un corpo JSON di errore standard.- 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 gestireMethodArgumentNotValidException
e restituire un errore400
con i dettagli dei campi non validi.- Esercizio 5 (Medio-Difficile): Paginazione
- Modifica il tuo
BookRepository
per estenderePagingAndSortingRepository
. Cambia il metodoGET /api/books
in modo che accetti parametripage
esize
(@RequestParam
). Usa un oggettoPageable
per recuperare una “pagina” di risultati dal repository. Restituisci un oggettoPage<Book>
dal controller (Spring lo serializzerà in JSON con metadati di paginazione).- 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 unBookDTOV2
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 OOP | Paradigma 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.
- 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. - 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.
- 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. - 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:
- Quando si carica un’entità dal database (es. con
findById()
), JPA non restituisce solo l’oggetto, ma lo inserisce anche nel Persistence Context. - 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.
- Dirty Checking: Qualsiasi modifica apportata a un’entità “gestita” (managed), cioè presente nel Persistence Context, viene tracciata automaticamente.
- 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 queryUPDATE
al database.
- Quando si carica un’entità dal database (es. con
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 unUserProfile
. - Annotazioni JPA:
@OneToOne
.
- Esempio: Un
-
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 moltiBook
. - Annotazioni JPA:
@OneToMany
sul latoAuthor
e@ManyToOne
sul latoBook
. La relazione bidirezionale è la più comune.
- Esempio: Un
-
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 moltiCourse
, e unCourse
può avere moltiStudent
. - Implementazione Relazionale: Richiede una tabella di giunzione intermedia (es.
student_course
) che contiene le chiavi esterne di entrambe le tabelle. - Annotazione JPA:
@ManyToMany
.
- Esempio: Un
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.
-
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 suoiBook
). - 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
sonoEAGER
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.
- Comportamento: Quando si carica l’entità principale (es. un
-
FetchType.LAZY
(Caricamento “Pigro”):- Comportamento: Quando si carica l’entità principale (es. un
Author
), le sue entità correlate (iBook
) 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
sonoLAZY
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à unaLazyInitializationException
.
- Comportamento: Quando si carica l’entità principale (es. un
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
traAuthor
eBook
. 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:
- Viene eseguita una query per selezionare tutti gli autori.
- 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. - Il risultato è 1 + N query al database, che è estremamente inefficiente.
Soluzioni al Problema N+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 unJOIN
.@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.
-
@EntityGraph
: Un’alternativa più moderna e a volte più pulita aJOIN 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. -
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.
- Esercizio 1 (Base): Entità e Repository
- Configura un database in-memory H2. Crea un’entità JPA
Prodotto
(@Entity
) conid
enome
. Crea un’interfacciaProdottoRepository
che estendeJpaRepository<Prodotto, Long>
. Scrivi un@CommandLineRunner
per salvare alcuni prodotti all’avvio e poi stamparli leggendoli dal repository.- Esercizio 2 (Facile): Query Derivate
- Aggiungi un attributo
categoria
all’entitàProdotto
. Aggiungi un metodoList<Prodotto> findByCategoria(String categoria);
all’interfacciaProdottoRepository
. Spring Data JPA implementerà la query automaticamente. Scrivi un test per verificare che funzioni.- 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
).- Esercizio 4 (Medio): Relazione One-to-Many
- Crea una nuova entità
Produttore
e stabilisci una relazione@OneToMany
daProduttore
aProdotto
. La relazione inversa (@ManyToOne
daProdotto
aProduttore
) deve essereFetchType.LAZY
. Scrivi un test per creare un produttore e associargli diversi prodotti.- 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:
- Recupera tutti i
Produttore
dal database (1 query).- 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.
- Esercizio 6 (Difficile): Risolvere il Problema N+1
- Risolvi il problema dell’esercizio 5 in due modi diversi nel
ProduttoreRepository
:
- JOIN FETCH: Crea un metodo con una
@Query
JPQL che usaJOIN FETCH
per caricare i produttori e i loro prodotti in una singola query (SELECT p FROM Produttore p JOIN FETCH p.prodotti
).- 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.