Astrazione in Java
Nasconde i dettagli implementativi dietro tipi astratti in Java usando classi astratte e interfacce.
L'astrazione è il quarto pilastro della OOP: descrivere cosa fa qualcosa senza impegnarsi in come lo fa. Si dichiarano le operazioni che un tipo supporta, si lascia l'implementazione alle classi concrete e si scrive il resto del programma contro il tipo astratto. Questo capitolo è la panoramica concettuale — i due meccanismi di Java per questo, abstract class e interface, hanno ciascuno un proprio capitolo dedicato.
Le due domande
Ogni dichiarazione di tipo in Java risponde a due domande:
- Cosa possono fare i chiamanti con i valori di questo tipo? (la sua API)
- Come viene implementata ciascuna di quelle operazioni? (il suo corpo)
Una classe concreta risponde a entrambe. Un tipo astratto risponde solo alla prima e lascia la seconda ai sottotipi:
public interface Shape {
double area(); // what — every Shape has an area
}
public class Circle implements Shape {
double r;
public Circle(double r) { this.r = r; }
public double area() { return Math.PI * r * r; } // how
}
public class Square implements Shape {
double side;
public Square(double side) { this.side = side; }
public double area() { return side * side; }
}Shape dice "ogni forma ha un'area." Circle e Square dicono come calcolarne una. Il codice che accetta una Shape non si preoccupa di quale:
double sumAreas(List<Shape> shapes) {
double sum = 0;
for (Shape s : shapes) sum += s.area();
return sum;
}Questa funzione è chiusa sull'astrazione. Funziona per Circle e Square oggi; per Triangle domani; per Polygon tra sei mesi. Nessuno dei nuovi tipi richiede alcuna modifica a sumAreas.
I due meccanismi di Java
| Meccanismo | Cosa fornisce | Quando usarlo |
|---|---|---|
| abstract class | Una classe parziale — alcuni metodi astratti, altri con corpo, più campi e costruttori | Quando i sottotipi condivideranno stato e codice infrastrutturale |
| interface | Un contratto puro (o quasi puro) — metodi che le classi implementanti devono fornire; nessuno stato di istanza | Quando i sottotipi devono solo concordare su un insieme di operazioni e possono non avere nulla in comune |
Una classe estende una sola classe astratta. Una classe può implementare molte interfacce. Questa asimmetria orienta molti design: se ci si trova a voler "ereditarietà multipla," le interfacce sono di solito la risposta.
Classi astratte — implementazione parziale
abstract su una classe significa "non puoi istanziarla direttamente — solo le sottoclassi." abstract su un metodo significa "nessun corpo qui; ogni sottoclasse concreta deve fornirne uno":
public abstract class Shape {
public abstract double area(); // every Shape must define this
// a concrete method, shared across all shapes
public final String describe() {
return getClass().getSimpleName() + " area=" + area();
}
}new Shape() è un errore di compilazione. new Circle() funziona. All'interno di describe, la chiamata area() viene dispacciata all'implementazione della sottoclasse effettiva — lo stesso meccanismo di polimorfismo di qualsiasi metodo sovrascritto.
Usa una classe astratta quando i sottotipi condividono davvero del codice. Se ti trovi a scrivere lo stesso helper in tre sottoclassi, è un segnale per spostarlo nel genitore.
Interfacce — il contratto
Un'interfaccia dichiara operazioni e lascia l'implementazione interamente a chi la implementa:
public interface Comparable<T> {
int compareTo(T other);
}
public class Money implements Comparable<Money> {
private final long cents;
public int compareTo(Money other) {
return Long.compare(this.cents, other.cents);
}
}Ora Money funziona ovunque sia attesa una Comparable — Collections.sort(...), TreeMap, Arrays.sort(...), i tuoi stessi algoritmi generici. La libreria standard e il tuo codice concordano su Comparable come astrazione condivisa; nessuno dei due lati conosce l'altro.
La grande maggioranza delle interfacce standard di Java (List, Map, Iterable, Runnable, Function, Comparator, AutoCloseable) funziona così: un contratto piccolo e focalizzato in cui molte classi concrete si inseriscono.
L'astrazione come leva di design
La parte meccanica dell'astrazione — la parola chiave abstract, la dichiarazione interface — è piccola. La parte difficile è scegliere quali astrazioni definire. Tre pattern che compaiono ripetutamente:
- Strategy. Definisci un'interfaccia per "l'algoritmo." Diverse implementazioni sostituiscono l'algoritmo senza modificare il codice che lo utilizza.
Comparatorè il classico esempio. - Template method. Una classe astratta implementa il flusso generale, con metodi astratti nei punti di variazione. Le sottoclassi riempiono i passaggi specifici. Il metodo
servicediHttpServletè un esempio famoso. - Plugin / punto di estensione. Una libreria pubblica un'interfaccia; il codice utente la implementa; la libreria richiama al suo interno. Servlet API, driver JDBC,
BeanPostProcessordi Spring.
In ogni caso, il guadagno è lo stesso: il codice che dipende dall'astrazione è chiuso rispetto ai cambiamenti nelle implementazioni, e aperto all'aggiunta di ulteriori implementazioni in seguito.
Incapsulamento vs astrazione
Questi due concetti sono cugini stretti e spesso si confondono.
- L'incapsulamento nasconde l'implementazione di una singola classe specifica (campi privati, metodi controllati). È una preoccupazione interna alla classe.
- L'astrazione nasconde quale classe si stia usando dietro un contratto condiviso. È una preoccupazione esterna alla classe.
Una classe con campi private e una API pubblica ordinata è incapsulata, ma non è ancora astratta — i chiamanti dipendono ancora da quella classe specifica. Sostituisci il tipo al confine dell'API con un'interfaccia e i chiamanti dipenderanno dal contratto. Ora puoi scambiare le implementazioni.
Per vederli lavorare insieme, guarda il capitolo sull'incapsulamento: l'incapsulamento blocca una singola classe, l'astrazione permette ai chiamanti di ignorare quale classe stiano usando.
Errori comuni
Alcune trappole colgono i nuovi arrivati all'astrazione:
- Cercare di istanziare un tipo astratto.
new Shape()è un errore di compilazione quandoShapeèabstracto un'interfaccia. Si istanzia un sottotipo concreto (new Circle(2)) e lo si assegna al riferimento astratto. - Astrarre troppo presto. Un'interfaccia con esattamente un'implementazione, scritta "nel caso ne servisse un'altra in seguito," è di solito peso morto. Aggiungi l'astrazione quando compare la seconda implementazione, o quando hai genuinamente bisogno di disaccoppiare due moduli. L'astrazione prematura aggiunge indirezione senza guadagnare flessibilità.
- Far trapelare il tipo concreto. Dichiarare un campo o parametro come
ArrayListinvece diList, o restituireHashMapinvece diMap, lega i chiamanti a quella classe specifica e annulla l'astrazione. Preferisci il tipo più astratto che esprima ancora ciò di cui hai bisogno. - Confondere "nessun corpo" con "non fa nulla." Un metodo astratto non ha corpo perché le sottoclassi devono fornirne uno. Un metodo concreto con corpo vuoto è un metodo reale che non fa nulla — un contratto molto diverso.
Un esempio pratico
Esegui il programma seguente. Esercita entrambi i meccanismi: una classe Shape astratta con codice describe condiviso, e una pura interfaccia Greeter. L'output atteso è:
Circle area=12.566370614359172
Square area=9.0
total = 21.57
Dear Alice,
hey Alice!Nota che totalArea e il ciclo sui greeter non nominano mai Circle, Square, FormalGreeter o CasualGreeter — parlano solo alle astrazioni Shape e Greeter.
Cosa viene dopo
Il capitolo successivo tratta la meccanica concreta delle classi astratte — metodi astratti, cosa permettono di ereditare a una sottoclasse, quando preferirle alle interfacce.