Vai al contenuto principale

OOP in Java: classi, oggetti, incapsulamento, ereditarietà e astrazione

Impara la programmazione orientata agli oggetti in Java con esempi pratici. Padroneggia classi, oggetti, ereditarietà, incapsulamento e classi astratte utilizzando un sistema di menu per ristorante.
· 15 min leggi

Scopri con l'IA

Java è costantemente tra le tre lingue più popolari al mondo. La sua adozione in ambiti come lo sviluppo software enterprise, le app Android e le applicazioni web su larga scala è impareggiabile. Il suo solido sistema di tipi, l’ecosistema esteso e la capacità di “write once, run anywhere” la rendono particolarmente allettante per costruire sistemi robusti e scalabili. In questo articolo esploreremo come le funzionalità di programmazione orientata agli oggetti di Java permettano agli sviluppatori di sfruttare efficacemente queste capacità, consentendo loro di creare applicazioni manutenibili e scalabili grazie a una corretta organizzazione e riuso del codice.

Una nota sull’organizzazione ed esecuzione del codice Java

Prima di iniziare a scrivere codice, facciamo un po’ di setup.

Come per la sua sintassi, Java ha regole rigide sull’organizzazione del codice.

Per prima cosa, ogni classe pubblica deve stare nel proprio file, chiamato esattamente come la classe ma con estensione .java. Quindi, se voglio scrivere una classe Laptop, il nome del file deve essere Laptop.java—sensibile alle maiuscole/minuscole. Puoi avere classi non pubbliche nello stesso file, ma è meglio separarle. So che stiamo andando avanti—parlando di come organizzare le classi prima ancora di scriverle—ma avere un’idea di massima su dove mettere le cose in anticipo è utile.

Tutti i progetti Java devono avere un file Main.java con la classe Main. Qui è dove testi le tue classi creando oggetti a partire da esse.

Per eseguire il codice Java, useremo IntelliJ IDEA, un IDE Java molto diffuso. Dopo aver installato IntelliJ:

  1. Crea un nuovo progetto Java (File > New > Project)
  2. Fai clic destro sulla cartella src per creare il file Main.java e incolla il seguente contenuto:
public class Main {
   public static void main(String[] args) {
       // Create and tests objects here
      
   }
}

Quando parliamo di classi, scriviamo il codice in file diversi da Main.java. Ma se parliamo di creare e testare oggetti, torniamo a Main.java.

Per eseguire il programma, puoi cliccare sul pulsante verde di play accanto al metodo main:

Una schermata di una finestra dell’editor in IntelliJ IDEA per Java

L’output verrà mostrato nella finestra Run in basso.

Se sei completamente nuovo a Java, dai un’occhiata al nostro corso Introduction to Java, che copre i fondamenti dei tipi di dato e del flusso di controllo in Java prima di proseguire.

Altrimenti, tuffiamoci subito.

Classi e oggetti in Java

Quindi, cosa sono esattamente le classi?

Le classi sono costrutti di programmazione in Java per rappresentare concetti del mondo reale. Per esempio, considera questa classe MenuItem (crea un file per scrivere questa classe nel tuo IDE):

public class MenuItem {
   public String name;
   public double price;
}

La classe ci fornisce un progetto o un modello per rappresentare vari elementi del menu di un ristorante. Modificando i due attributi della classe, name, e price, possiamo creare innumerevoli oggetti menu come un burger o un’insalata.

Quindi, per creare una classe in Java, inizi con una riga che descrive il livello di accesso della classe (private, public o protected) seguito dal nome della classe. Subito dopo le parentesi, definisci gli attributi della tua classe.

Ma come creiamo oggetti che appartengono a questa classe? Java lo consente tramite i metodi costruttori:

public class MenuItem {
   public String name;
   public double price;
  
   // Constructor
   public MenuItem(String name, double price) {
       this.name = name;
       this.price = price;
   }
}

Un costruttore è un metodo speciale che viene chiamato quando creiamo un nuovo oggetto da una classe. Inizializza gli attributi dell’oggetto con i valori che forniamo. Nell’esempio sopra, il costruttore accetta i parametri name e price e li assegna ai campi dell’oggetto usando la parola chiave ‘this’ per riferirsi a una futura istanza dell’oggetto.

La sintassi del costruttore è diversa dagli altri metodi della classe perché non richiede di specificare un tipo di ritorno. Inoltre, il costruttore deve avere lo stesso nome della classe e dovrebbe avere lo stesso numero di attributi dichiarati dopo la definizione della classe. Sopra, il costruttore sta creando due attributi perché ne abbiamo dichiarati due dopo la definizione della classe: name e price.

Dopo aver scritto la classe e il suo costruttore, puoi crearne istanze (oggetti) nel tuo metodo main:

public class Main {
   public static void main(String[] args) {
       // Create objects here
       MenuItem burger = new MenuItem("Burger", 3.5);
       MenuItem salad = new MenuItem("Salad", 2.5);
       System.out.println(burger.name + ", " + burger.price);
   }
}

Output:

Burger, 3.5

Qui sopra stiamo creando due oggetti MenuItem nelle variabili burger e salad. Come richiesto in Java, il tipo della variabile deve essere dichiarato, ovvero MenuItem. Poi, per creare un’istanza della nostra classe, scriviamo la parola chiave new seguita dall’invocazione del costruttore.

Oltre al costruttore, puoi creare metodi normali che danno comportamento alla tua classe. Per esempio, sotto aggiungiamo un metodo per calcolare il prezzo totale dopo le tasse:

public class MenuItem {
   public String name;
   public double price;
  
   // Constructor
   public MenuItem(String name, double price) {
       this.name = name;
       this.price = price;
   }
  
   // Method to calculate price after tax
   public double getPriceAfterTax() {
       double taxRate = 0.08; // 8% tax rate
       return price + (price * taxRate);
   }
}

Ora possiamo calcolare il prezzo totale, tasse incluse:

public class Main {
   public static void main(String[] args) {
       MenuItem burger = new MenuItem("Burger", 3.5);
       System.out.println("Price after tax: $" + burger.getPriceAfterTax());
   }
}

Output:

Price after tax: $3.78

Incapsulamento

Lo scopo delle classi è fornire un modello per creare oggetti. Questi oggetti verranno poi utilizzati da altri script o programmi. Per esempio, i nostri oggetti MenuItem possono essere usati da un’interfaccia utente che ne mostra nome, prezzo e immagine su uno schermo.

Per questo motivo, dobbiamo progettare le nostre classi in modo che le loro istanze possano essere usate solo come previsto. Al momento, la nostra classe MenuItem è molto basilare e soggetta a errori. Qualcuno potrebbe creare oggetti con attributi assurdi, come una apple pie a prezzo negativo o un sandwich da un milione di dollari:

// Inside Main.java
MenuItem applePie = new MenuItem("Apple Pie", -5.99);  // Negative price!
MenuItem sandwich = new MenuItem("Sandwich", 1000000); // Unreasonably expensive

System.out.println("Apple pie price: $" + applePie.price);
System.out.println("Sandwich price: $" + sandwich.price);

Quindi, la prima cosa da fare dopo aver scritto una classe è proteggere i suoi attributi limitando come vengono creati e accessi. Per iniziare, vogliamo consentire solo valori positivi per price e impostare un valore massimo per evitare di mostrare per errore elementi ridicolmente costosi.

Java ci permette di farlo usando i metodi setter:

public class MenuItem {
   private String name;
   private double price;
   private static final double MAX_PRICE = 100.0;
  
   public MenuItem(String name, double price) {
       this.name = name;
       setPrice(price);
   }
  
   public void setPrice(double price) {
       if (price < 0) {
           throw new IllegalArgumentException("Price cannot be negative");
       }
       if (price > MAX_PRICE) {
           throw new IllegalArgumentException("Price cannot exceed $" + MAX_PRICE);
       }
       this.price = price;
   }
  
}

Vediamo cosa c’è di nuovo nel blocco di codice sopra:

1. Abbiamo reso gli attributi privati aggiungendo la parola chiave private. Questo significa che possono essere accessibili solo all’interno della classe MenuItem. L’incapsulamento parte da questo passaggio cruciale.

2. Abbiamo aggiunto una nuova costante MAX_PRICE che è:

  • private (accessibile solo all’interno della classe)
  • static (condivisa tra tutte le istanze)
  • final (non può essere modificata dopo l’inizializzazione)
  • impostata a $100.0 come prezzo massimo ragionevole

3. Abbiamo aggiunto un metodo setPrice() che:

  • Accetta un parametro price
  • Valida che il prezzo non sia negativo
  • Valida che il prezzo non superi MAX_PRICE
  • Lancia IllegalArgumentException con messaggi descrittivi se la validazione fallisce
  • Imposta il prezzo solo se tutte le validazioni passano

4. Abbiamo modificato il costruttore per usare setPrice() invece di assegnare direttamente il prezzo. Questo assicura che la validazione del prezzo avvenga durante la creazione dell’oggetto.

Abbiamo appena implementato uno dei pilastri fondamentali del buon design orientato agli oggetti — l’incapsulamento. Questo paradigma impone la protezione dei dati e un accesso controllato agli attributi dell’oggetto, garantendo che i dettagli di implementazione interni siano protetti da interferenze esterne e possano essere modificati solo tramite interfacce ben definite.

Rendiamo il concetto ancora più chiaro applicando l’incapsulamento all’attributo name. Immagina un bar che serve solo latte, cappuccino, espresso, americano e mocha.

Quindi, i nomi dei nostri elementi del menu possono essere solo uno tra quelli in questa lista. Ecco come possiamo imporlo nel codice:

// Rest of the class here
...
private static final String[] VALID_NAMES = {"latte", "cappuccino", "espresso", "americano", "mocha"};
private String name;
public void setName(String name) {
   String lowercaseName = name.toLowerCase();
   for (String validName : VALID_NAMES) {
       if (validName.equals(lowercaseName)) {
           this.name = name;
           return;
       }
   }
   throw new IllegalArgumentException("Invalid drink name. Must be one of: " + String.join(", ", VALID_NAMES));
}

Il codice sopra implementa la validazione del nome per gli elementi del menu di un bar. Vediamolo nel dettaglio:

1. Per prima cosa, definisce un array privato statico e finale VALID_NAMES che contiene gli unici nomi di bevande consentiti: latte, cappuccino, espresso, americano e mocha. Questo array è:

  • private: accessibile solo all’interno della classe
  • static: condiviso tra tutte le istanze
  • final: non può essere modificato dopo l’inizializzazione

2. Dichiara un campo privato String name per memorizzare il nome della bevanda

3. Il metodo setName() implementa la logica di validazione:

  • Accetta un parametro String name
  • Lo converte in minuscolo per rendere il confronto case-insensitive
  • Itera sull’array VALID_NAMES
  • Se trova una corrispondenza, imposta il nome e ritorna
  • Se non trova corrispondenze, lancia un’IllegalArgumentException con un messaggio descrittivo che elenca tutte le opzioni valide

Ecco la classe completa finora:

public class MenuItem {
   private String name;
   private double price;

   private static final String[] VALID_NAMES = {"latte", "cappuccino", "espresso", "americano", "mocha"};

   private static final double MAX_PRICE = 100.0;

   public MenuItem(String name, double price) {
       setName(name);
       setPrice(price);
   }

   public void setName(String name) {
       String lowercaseName = name.toLowerCase();
       for (String validName : VALID_NAMES) {
           if (validName.equals(lowercaseName)) {
               this.name = name;
               return;
           }
       }
       throw new IllegalArgumentException("Invalid drink name. Must be one of: " + String.join(", ", VALID_NAMES));
   }

   public void setPrice(double price) {
       if (price < 0) {
           throw new IllegalArgumentException("Price cannot be negative");
       }
       if (price > MAX_PRICE) {
           throw new IllegalArgumentException("Price cannot exceed $" + MAX_PRICE);
       }
       this.price = price;
   }
}

Dopo aver protetto il modo in cui gli attributi vengono creati, vogliamo anche proteggere come vengono accessi. Questo si fa usando i metodi getter:

public class MenuItem {
   // Rest of the code here
   ...
   public String getName() {
       return name;
   }
   public double getPrice() {
       return price;
   }
}

I metodi getter forniscono accesso controllato agli attributi privati di una classe. Risolvono il problema dell’accesso diretto agli attributi che può portare a modifiche indesiderate e rompere l’incapsulamento.

Per esempio, senza getter, potremmo accedere agli attributi direttamente:

MenuItem item = new MenuItem("Latte", 4.99);
String name = item.name; // Direct access to attribute
item.name = "INVALID"; // Can modify directly, bypassing validation

Con i getter, imponiamo un accesso corretto:

MenuItem item = new MenuItem("Latte", 4.99);
String name = item.getName(); // Controlled access through getter
// item.name = "INVALID"; // Not allowed - must use setName() which validates

Questo incapsulamento:

  1. Protegge l’integrità dei dati prevenendo modifiche non valide
  2. Ci consente di cambiare l’implementazione interna senza influenzare il codice che usa la classe
  3. Fornisce un unico punto di accesso che può includere logica aggiuntiva se necessario
  4. Rende il codice più manutenibile e meno soggetto a bug

Ereditarietà

La nostra classe inizia a sembrare valida, ma presenta diversi problemi. Per esempio, per un ristorante grande che serve molti tipi di piatti e bevande, la classe non è abbastanza flessibile.

Se vogliamo aggiungere diversi tipi di alimenti, incontreremo varie sfide. Alcuni piatti possono essere preparati per l’asporto, mentre altri richiedono consumo immediato. Gli elementi del menu possono avere prezzi e sconti diversi. I piatti possono necessitare di monitoraggio della temperatura o conservazione speciale. Le bevande possono essere calde o fredde con ingredienti personalizzabili. Gli elementi possono richiedere informazioni sugli allergeni e opzioni di porzione. L’attuale sistema non gestisce questi requisiti variabili.

L’ereditarietà offre una soluzione elegante a tutti questi problemi. Ci permette di creare versioni specializzate degli elementi del menu definendo una classe base MenuItem con attributi comuni e poi creando classi figlie che ereditano queste basi aggiungendo funzionalità uniche. 

Per esempio, potremmo avere una classe Drink per le bevande con opzioni di temperatura, una classe Food per elementi che richiedono consumo immediato e una classe Dessert per elementi con esigenze di conservazione speciale — tutte ereditano la funzionalità core degli elementi del menu.

Estendere le classi

Implementiamo queste idee partendo da Drink:

public class Drink extends MenuItem {
   private boolean isCold;
  
   public Drink(String name, double price, boolean isCold) {
       this.name = name;
       this.price = price;
       this.isCold = isCold;
   }
  
   public boolean getIsCold() {
       return isCold;
   }
  
   public void setIsCold(boolean isCold) {
       this.isCold = isCold;
   }
}

Per definire una classe figlia che eredita da una classe genitore, usiamo la parola chiave extends dopo il nome della classe figlia seguita dalla classe genitore. Dopo la definizione della classe, definiamo i nuovi attributi che questa figlia possiede e implementiamo il suo costruttore.

Ma nota come dobbiamo ripetere l’inizializzazione di name e price insieme a isCold. Non è l’ideale perché la classe genitore potrebbe avere centinaia di attributi. Inoltre, il codice sopra genererà un errore in compilazione perché non è il modo corretto di inizializzare gli attributi della classe genitore. Il modo corretto è usare la parola chiave super:

public class Drink extends MenuItem {
   private boolean isCold;
  
   public Drink(String name, double price, boolean isCold) {
       super(name, price);
       this.isCold = isCold;
   }
  
   public boolean getIsCold() {
       return isCold;
   }
  
   public void setIsCold(boolean isCold) {
       this.isCold = isCold;
   }
}

La parola chiave super viene usata per chiamare il costruttore della classe genitore. In questo caso, super(name, price) chiama il costruttore di MenuItem per inizializzare quegli attributi, evitando duplicazione di codice. Dobbiamo solo inizializzare il nuovo attributo isCold specifico della classe Drink.

La parola chiave è molto flessibile perché puoi usarla per riferirti alla classe genitore in qualsiasi parte della classe figlia. Per esempio, per chiamare un metodo del genitore, usi super.methodName() mentre super.attributeName è per gli attributi.

Override dei metodi

Ora, supponiamo di voler aggiungere un nuovo metodo alle nostre classi per calcolare il prezzo totale dopo le tasse. Poiché diversi elementi del menu possono avere aliquote fiscali differenti (ad esempio, cibo preparato vs. bevande confezionate), possiamo usare l’override dei metodi per implementare calcoli fiscali specifici in ogni classe figlia mantenendo un nome di metodo comune nella classe genitore.

Ecco come si presenta:

public class MenuItem {
   // Rest of the MenuItem class
  
   public double calculateTotalPrice() {
       // Default tax rate of 10%
       return price * 1.10;
   }
}
public class Food extends MenuItem {
   private boolean isVegetarian;
  
   public Food(String name, double price, boolean isVegetarian) {
       super(name, price);
       this.isVegetarian = isVegetarian;
   }
  
   @Override
   public double calculateTotalPrice() {
       // Food has 15% tax
       return super.getPrice() * 1.15;
   }
}
public class Drink extends MenuItem {
   private boolean isCold;
  
   public Drink(String name, double price, boolean isCold) {
       super(name, price);
       this.isCold = isCold;
   }
  
   @Override
   public double calculateTotalPrice() {
       // Drinks have 8% tax
       return super.getPrice() * 1.08;
   }
}

In questo esempio, l’override dei metodi consente a ciascuna sottoclasse di fornire la propria implementazione di calculateTotalPrice():

La classe base MenuItem definisce un calcolo fiscale predefinito del 10%.

Quando le classi Food e Drink estendono MenuItem, sovrascrivono questo metodo per implementare le proprie aliquote fiscali:

  • Gli alimenti hanno un’aliquota più alta del 15%
  • Le bevande hanno un’aliquota più bassa dell’8%

L’annotazione @Override è usata per indicare esplicitamente che questi metodi stanno sovrascrivendo il metodo della classe genitore. Questo aiuta a intercettare errori se la firma del metodo non corrisponde a quella della classe genitore.

Ogni sottoclasse può comunque accedere al prezzo della classe genitore usando super.getPrice(), dimostrando come i metodi sovrascritti possano utilizzare la funzionalità della classe genitore aggiungendo il proprio comportamento.

In breve, l’override dei metodi è una parte integrante dell’ereditarietà che consente alle sottoclassi di fornire la propria implementazione dei metodi definiti nella classe genitore, abilitando comportamenti più specifici mantenendo la stessa firma del metodo.

Classi astratte

La nostra gerarchia di MenuItem funziona, ma c’è un problema: chiunque dovrebbe poter creare un semplice oggetto MenuItem? Dopotutto, nel nostro ristorante ogni elemento del menu è o un Food o un Drink — non esiste un “elemento di menu generico”.

Possiamo evitarlo rendendo MenuItem una classe astratta. Una classe astratta fornisce solo un blueprint di base — può essere usata solo come classe genitore per l’ereditarietà, non istanziata direttamente.

Per rendere MenuItem astratta, aggiungiamo la parola chiave abstract dopo il suo modificatore di accesso:

public abstract class MenuItem {
   private String name;
   private double price;
  
   public MenuItem(String name, double price) {
       setName(name);
       setPrice(price);
   }
  
   // Existing getters/setters remain the same
  
   // Make this method abstract - every subclass MUST implement it
   public abstract double calculateTotalPrice();
}

Le classi astratte possono anche avere metodi astratti come calculateTotalPrice() qui sopra. Questi metodi astratti fungono da contratti che obbligano le sottoclassi a fornire le proprie implementazioni. In altre parole, qualsiasi metodo astratto in una classe astratta deve essere implementato dalle classi figlie.

Quindi, riscriviamo Food e Drink tenendo conto di queste modifiche:

public class Food extends MenuItem {
   private boolean isVegetarian;
  
   public Food(String name, double price, boolean isVegetarian) {
       super(name, price);
       this.isVegetarian = isVegetarian;
   }
  
   @Override
   public double calculateTotalPrice() {
       return getPrice() * 1.15;  // 15% tax
   }
}
public class Drink extends MenuItem {
   private boolean hasCaffeine;
  
   public Drink(String name, double price, boolean hasCaffeine) {
       super(name, price);
       this.hasCaffeine = hasCaffeine;
   }
  
   @Override
   public double calculateTotalPrice() {
       return getPrice() * 1.10;  // 10% tax
   }
}

Attraverso questa implementazione del sistema di menu, abbiamo visto come astrazione ed ereditarietà lavorino insieme per creare codice flessibile e manutenibile che può adattarsi facilmente a diversi requisiti di business.

Conclusione

Oggi abbiamo dato uno sguardo a ciò di cui Java è capace come linguaggio orientato agli oggetti. Abbiamo coperto le basi di classi, oggetti e alcuni pilastri chiave dell’OOP: incapsulamento, ereditarietà e astrazione tramite un sistema di menu per ristorante.

Per rendere questo sistema pronto per la produzione, ci sono ancora molte cose da imparare, come le interfacce (parte dell’astrazione), il polimorfismo e i design pattern OOP. Per approfondire questi concetti, consulta il nostro corso Introduction to OOP in Java.

Se vuoi mettere alla prova le tue conoscenze di Java, prova a rispondere ad alcune delle domande presenti nel nostro articolo sulle domande di colloquio Java.

FAQ su OOP in Java

Quali sono i prerequisiti per seguire questo tutorial OOP in Java?

Dovresti avere conoscenze di base di programmazione in Java, inclusi tipi di dato, variabili, flusso di controllo (if/else, loop) e sintassi di base. Il nostro corso Introduction to Java copre questi fondamentali se ti serve un ripasso.

Perché usare un sistema di menu per ristorante per spiegare i concetti OOP?

Un sistema di menu per ristorante è un esempio intuitivo che dimostra applicazioni reali dei principi OOP. Mostra naturalmente l’ereditarietà (diversi tipi di elementi del menu), l’incapsulamento (protezione dei valori di prezzo e nome) e le interfacce (programmi fedeltà, controllo della temperatura). La maggior parte delle persone capisce come funziona un ristorante, rendendo i concetti più facili da afferrare.

Qual è la differenza tra classi astratte e interfacce in Java?
  • Una classe può estendere una sola classe astratta ma implementare più interfacce.
  • Le classi astratte possono avere campi e costruttori; le interfacce no.
  • Le classi astratte possono avere sia metodi astratti sia concreti; le interfacce tradizionalmente hanno solo metodi astratti (prima di Java 8).
  • Le classi astratte forniscono un’implementazione di base, mentre le interfacce definiscono un contratto.
Bexruz (Bex) Tuychiev

Autore

Bexruz (Bex) Tuychiev

Sono un creator di contenuti sulla data science con oltre 2 anni di esperienza e uno dei più seguiti su Medium. Mi piace scrivere articoli dettagliati su AI e ML con un pizzico di sarcasmo, perché bisogna pur fare qualcosa per renderli un po’ meno noiosi. Ho pubblicato oltre 130 articoli e un corso su DataCamp, con un altro in arrivo. I miei contenuti sono stati visti da oltre 5 milioni di persone, 20.000 delle quali sono diventate follower sia su Medium che su LinkedIn. 

Argomenti

Correlato