OOP in Java: Classi, Oggetti, Incapsulamento, Ereditarietà e Astrazione

Java è costantemente una delle tre lingue più popolari al mondo. La sua adozione in campi come lo sviluppo di software aziendale, le app mobili Android e le applicazioni web su larga scala è ineguagliabile. Il suo forte sistema di tipi, l’ecosistema esteso e la capacità di “scrivere una volta, eseguire ovunque” lo rendono particolarmente attraente per la costruzione di sistemi robusti e scalabili. In questo articolo, esploreremo come le funzionalità di programmazione orientata agli oggetti di Java consentano agli sviluppatori di sfruttare efficacemente queste capacità, consentendo loro di costruire applicazioni manutenibili e scalabili attraverso un’adeguata organizzazione e riutilizzo del codice.

Una Nota sull’Organizzazione e l’Esecuzione del Codice Java

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

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

Prima di tutto, ogni classe pubblica deve trovarsi nel proprio file, con lo stesso nome della classe ma con un’estensione .java. Quindi, se voglio scrivere una classe Laptop, il nome del file deve essere Laptop.javasensibile alle maiuscole e minuscole. È possibile avere classi non pubbliche nello stesso file, ma è meglio tenerle separate. So che stiamo andando avanti—parlando dell’organizzazione delle classi anche prima di scriverle—ma avere un’idea approssimativa di dove mettere le cose in anticipo è una buona idea.

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

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

  1. Crea un nuovo progetto Java (File > Nuovo > Progetto)
  2. Fai clic con il tasto destro sulla cartella src per creare il Main.java file e incolla il seguente contenuto:
public class Main { public static void main(String[] args) { // Crea e testa oggetti qui } }

Ogni volta che parliamo di classi, scriviamo codice in file diversi rispetto al Main.java. Ma se stiamo parlando di creare e testare oggetti, passiamo a Main.java.

Per eseguire il programma, puoi fare clic sul pulsante verde di riproduzione accanto al metodo principale:

L’output verrà mostrato nella finestra degli strumenti Esegui in basso.

Se sei completamente nuovo a Java, ti consigliamo di dare un’occhiata al nostro Corso di Introduzione a Java, che copre le basi dei tipi di dati Java e del flusso di controllo prima di continuare.

Altrimenti, tuffiamoci subito.

Classi e Oggetti Java

Quindi, cosa sono esattamente le classi?

Le classi sono costrutti di programmazione in Java per rappresentare concetti del mondo reale. Ad 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 in un ristorante. Cambiando i due attributi della classe, nome, e prezzo, possiamo creare innumerevoli oggetti del menu come un hamburger o un’insalata.

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

Ma come creiamo oggetti che appartengono a questa classe? Java consente di farlo attraverso i metodi costruttori:

public class MenuItem { public String name; public double price; // Costruttore 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 prende un parametro nome e prezzo e li assegna ai campi dell’oggetto usando la parola chiave ‘this’ per riferirsi a un’istanza futura dell’oggetto.

La sintassi per il costruttore è diversa rispetto agli altri metodi di 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. Qui, il costruttore sta creando due attributi perché ne abbiamo dichiarati due dopo la definizione della classe: name e price.

Dopo aver scritto la tua classe e il relativo costruttore, puoi creare istanze (oggetti) di essa nel tuo metodo principale:

public class Main { public static void main(String[] args) { // Crea gli oggetti qui 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

Sopra, stiamo creando due MenuItem oggetti in variabili burger e salad. Come richiesto in Java, il tipo della variabile deve essere dichiarato, che è MenuItem. Poi, per creare un’istanza della nostra classe, scriviamo la nuova parola chiave seguita dall’invocazione del metodo costruttore.

Oltre al costruttore, puoi creare metodi regolari che conferiscono comportamento alla tua classe. Ad esempio, di seguito, aggiungiamo un metodo per calcolare il prezzo totale dopo le tasse:

public class MenuItem { public String name; public double price; // Costruttore public MenuItem(String name, double price) { this.name = name; this.price = price; } // Metodo per calcolare il prezzo dopo le tasse public double getPriceAfterTax() { double taxRate = 0.08; // Aliquota fiscale dell'8% return price + (price * taxRate); } }

Ora possiamo calcolare il prezzo totale, comprese le tasse:

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 la creazione di oggetti. Questi oggetti saranno poi utilizzati da altri script o programmi. Ad esempio, i nostri MenuItem oggetti possono essere utilizzati da un’interfaccia utente che visualizza il loro nome, prezzo e immagine su uno schermo.

Per questo motivo, dobbiamo progettare le nostre classi in modo che le loro istanze possano essere utilizzate solo come abbiamo previsto. Al momento, la nostra MenuItem classe è molto basilare e soggetta agli errori. Una persona potrebbe creare oggetti con attributi ridicoli, come una torta di mele dal prezzo negativo o un panino da un milione di dollari:

// All'interno di Main.java MenuItem applePie = new MenuItem("Apple Pie", -5.99); // Prezzo negativo! MenuItem sandwich = new MenuItem("Sandwich", 1000000); // Eccessivamente costoso System.out.println("Apple pie price: $" + applePie.price); System.out.println("Sandwich price: $" + sandwich.price);

Quindi, il primo compito dopo aver scritto una classe è proteggere i suoi attributi limitando come vengono creati e accessi. Per cominciare, vogliamo consentire solo valori positivi per prezzo e impostare un valore massimo per evitare di visualizzare accidentalmente articoli ridicolmente costosi.

Java ci permette di raggiungere questo obiettivo usando 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; } }

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

1. Abbiamo reso gli attributi privati aggiungendo la private parola chiave. Ciò significa che possono essere accessibili solo all’interno della classe MenuItem. L’incapsulamento inizia con questo passo cruciale.

2. Abbiamo aggiunto una nuova costante MAX_PRICE che è:

  • privata (accessibile solo all’interno della classe)
  • statico (condiviso tra tutte le istanze)
  • finale (non può essere cambiato dopo l’inizializzazione)
  • impostato a $100.0 come prezzo massimo ragionevole

3. Abbiamo aggiunto un setPrice() metodo che:

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

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

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

Rafforziamo il concetto applicando l’incapsulamento all’attributo nome. Immaginiamo di avere un bar che serve solo lattes, cappuccini, espressi, americani e mochas.

Quindi, i nomi degli elementi del nostro menu possono essere solo uno degli elementi di questa lista. Ecco come possiamo far rispettare ciò nel codice:

// Resto della classe qui ... 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 in un bar. Vediamolo nel dettaglio:

1. Innanzitutto, definisce un array VALID_NAMES privato, statico e finale che contiene solo i nomi delle bevande consentiti: latte, cappuccino, espresso, americano e mocha. Questo array è:

  • privato: accessibile solo all’interno della classe
  • statico: condiviso tra tutte le istanze
  • finale: non può essere modificato dopo l’inizializzazione

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

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

  • Prende un parametro String nome
  • Lo converte in minuscolo per rendere il confronto senza distinzione tra maiuscole e minuscole
  • Cicla attraverso l’array VALID_NAMES
  • Se viene trovata una corrispondenza, imposta il nome e restituisce
  • Se non viene trovata alcuna corrispondenza, genera 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 il modo in cui vengono accessi. Questo viene fatto utilizzando i metodi getter:

public class MenuItem { // Resto del codice qui ... public String getName() { return name; } public double getPrice() { return price; } }

I metodi getter forniscono un 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.

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

MenuItem item = new MenuItem("Latte", 4.99); String name = item.name; // Accesso diretto all'attributo item.name = "INVALID"; // Può essere modificato direttamente, bypassando la validazione

Con i getter, imponiamo un accesso appropriato:

MenuItem item = new MenuItem("Latte", 4.99); String name = item.getName(); // Accesso controllato tramite getter // item.name = "INVALID"; // Non consentito - deve essere usato setName() che valida

Questa incapsulazione:

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

Ereditarietà

La nostra classe sta iniziando a sembrare buona, ma ci sono molti problemi con essa. Ad esempio, per un grande ristorante che serve molti tipi di piatti e bevande, la classe non è abbastanza flessibile.

Se vogliamo aggiungere diversi tipi di articoli alimentari, ci troveremo ad affrontare diverse sfide. Alcuni piatti possono essere preparati per il takeout, mentre altri necessitano di essere consumati immediatamente. Gli articoli del menu possono avere prezzi e sconti variabili. I piatti possono richiedere monitoraggio della temperatura o stoccaggio speciale. Le bevande possono essere calde o fredde con ingredienti personalizzabili. Gli articoli possono necessitare di informazioni sugli allergeni e opzioni di porzione. Il sistema attuale non gestisce questi requisiti variabili.

L’ereditarietà fornisce una soluzione elegante a tutti questi problemi. Ci consente 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.

Ad esempio, potremmo avere una classe Drink per le bevande con opzioni di temperatura, una classe Food per gli articoli che necessitano di consumo immediato, e una classe Dessert per gli articoli con esigenze di conservazione speciali — tutti ereditando la funzionalità di base degli elementi del menu.

Estensione delle 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, utilizziamo la parola chiave extends dopo il nome della classe figlia seguito dalla classe genitore. Dopo la definizione della classe, definiamo eventuali nuovi attributi di questa classe figlia e implementiamo il suo costruttore.

Ma nota come dobbiamo ripetere l’inizializzazione di nome e prezzo insieme a isCold. Questo non è ideale perché la classe genitore potrebbe avere centinaia di attributi. Inoltre, il codice sopra genererà un errore quando lo compili perché non è il modo corretto di inizializzare gli attributi della classe genitore. Il modo corretto sarebbe utilizzando 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 keyword super viene utilizzata per chiamare il costruttore della classe genitore. In questo caso, super(name, price) chiama il costruttore di MenuItem per inizializzare quegli attributi, evitando la duplicazione del codice. Abbiamo solo bisogno di inizializzare il nuovo attributo isCold specifico della classe Drink.

La parola chiave è molto flessibile perché puoi usarla per riferirti alla classe madre in qualsiasi parte della classe figlia. Ad esempio, per chiamare un metodo della madre, 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é i diversi articoli del menu possono avere aliquote fiscali diverse (ad esempio, cibo preparato contro bevande confezionate), possiamo utilizzare l’override dei metodi per implementare calcoli fiscali specifici in ciascuna classe figlia mantenendo un nome di metodo comune nella classe madre.

Ecco come appare:

public class MenuItem { // Resto della classe MenuItem public double calculateTotalPrice() { // Aliquota fiscale predefinita del 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() { // Il cibo ha il 15% di tasse 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() { // Le bevande hanno l'8% di tasse return super.getPrice() * 1.08; } }

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

La classe MenuItem definisce un calcolo dell’imposta predefinito del 10%.

QuandoCibo eBevanda estendono le classiMenuItem, sovrascrivono questo metodo per implementare le proprie aliquote fiscali:

  • Gli elementi alimentari hanno un’aliquota fiscale più alta del 15%
  • Le bevande hanno un’aliquota fiscale inferiore all’8%

Il @Override annotazione viene utilizzata per indicare esplicitamente che questi metodi sovrascrivono il metodo della classe genitore. Questo aiuta a individuare gli errori se la firma del metodo non corrisponde a quella della classe genitore.

Ogni sottoclasse può comunque accedere al prezzo della classe genitore utilizzando 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 MenuItem gerarchia di classi funziona, ma c’è un problema: dovrebbe essere possibile a chiunque creare un semplice MenuItem oggetto? Dopotutto, nel nostro ristorante, ogni elemento del menu è o un Cibo o una Bevanda – non esiste un “elemento del menu generico.”

Possiamo prevenire questo rendendo MenuItem una classe astratta. Una classe astratta fornisce solo un progetto di base – può essere utilizzata solo come classe genitore per l’ereditarietà, non può essere istanziata direttamente.

Per rendere MenuItem astratto, 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); } // I getter/setter esistenti rimangono gli stessi // Rendi questo metodo astratto - ogni sottoclasse DEVE implementarlo public abstract double calculateTotalPrice(); }

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

Quindi, riscriviamo Cibo e Bevanda tenendo presente questi cambiamenti:

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% di tasse } } 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% di tasse } }

Attraverso l’implementazione di questo sistema di menu, abbiamo visto come l’astrazione e l’ereditarietà lavorino insieme per creare codice flessibile e manutenibile che può adattarsi facilmente a diversi requisiti aziendali.

Conclusione

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

Per rendere questo sistema pronto per la produzione, hai ancora molte cose da imparare, come le interfacce (parte dell’astrazione), il polimorfismo e i modelli di design OOP. Per saperne di più su questi concetti, fai riferimento al nostro Corso introduttivo all’OOP in Java.

Se desideri testare le tue conoscenze di Java, prova a rispondere a alcune delle domande nel nostro articolo sulle Domande di Intervista su Java.

Source:
https://www.datacamp.com/tutorial/oop-in-java