Padroneggiare l'OOP: una guida pratica all'ereditarietà, alle interfacce e alle classi astratte
Pubblicato: 2022-03-10Per quanto ne so, è raro imbattersi in contenuti didattici nel campo dello sviluppo di software che forniscono un'adeguata combinazione di informazioni teoriche e pratiche. Se dovessi indovinare il motivo, suppongo sia perché le persone che si concentrano sulla teoria tendono a dedicarsi all'insegnamento e le persone che si concentrano sulle informazioni pratiche tendono a essere pagate per risolvere problemi specifici, utilizzando linguaggi e strumenti specifici.
Questa è, ovviamente, un'ampia generalizzazione, ma se la accettiamo brevemente per amor di argomenti, ne consegue che molte persone (non tutte le persone) che assumono il ruolo di insegnante, tendono ad essere scarse o completamente incapaci di spiegare le conoscenze pratiche relative a un particolare concetto.
In questo articolo, farò del mio meglio per discutere tre meccanismi fondamentali che troverai nella maggior parte dei linguaggi di programmazione orientata agli oggetti (OOP): ereditarietà , interfacce (ovvero protocolli ) e classi astratte . Piuttosto che darti spiegazioni verbali tecniche e complesse di ogni meccanismo , farò del mio meglio per concentrarmi su ciò che fanno e quando utilizzarli.
Tuttavia, prima di affrontarli singolarmente, vorrei discutere brevemente di cosa significhi dare una spiegazione teoricamente valida, ma praticamente inutile. La mia speranza è che tu possa usare queste informazioni per aiutarti a setacciare diverse risorse educative ed evitare di incolpare te stesso quando le cose non hanno senso.
Diversi gradi di conoscenza
Conoscere i nomi
Conoscere il nome di qualcosa è probabilmente la forma più superficiale di conoscenza. In effetti, un nome è generalmente utile solo nella misura in cui è comunemente usato da molte persone per riferirsi alla stessa cosa e/o aiuta a descrivere la cosa. Sfortunatamente, come ha scoperto chiunque abbia speso del tempo in questo campo, molte persone usano nomi diversi per la stessa cosa (es. interfacce e protocolli ), gli stessi nomi per cose diverse (es . moduli e componenti ), o nomi che sono esoterici per il punto di essere assurdo (es. O Monad ). In definitiva, i nomi sono solo puntatori (o riferimenti) a modelli mentali e possono essere di vari gradi di utilità.
Per rendere questo campo ancora più difficile da studiare, azzarderei a indovinare che per la maggior parte delle persone, scrivere codice è (o almeno è stata) un'esperienza davvero unica. Ancora più complicato è capire come quel codice venga infine compilato in linguaggio macchina e rappresentato nella realtà fisica come una serie di impulsi elettrici che cambiano nel tempo. Anche se si possono ricordare i nomi dei processi, dei concetti e dei meccanismi impiegati in un programma, non vi è alcuna garanzia che i modelli mentali che si creano per tali cose siano coerenti con i modelli di un altro individuo; figuriamoci se sono oggettivamente accurati.
È per questi motivi, oltre al fatto che non ho una buona memoria per il gergo, che ritengo i nomi l'aspetto meno importante del sapere qualcosa. Questo non vuol dire che i nomi siano inutili, ma in passato ho imparato e impiegato molti modelli di design nei miei progetti, solo per conoscere il nome comunemente usato mesi o addirittura anni dopo.
Conoscere le definizioni e le analogie verbali
Le definizioni verbali sono il naturale punto di partenza per descrivere un nuovo concetto. Tuttavia, come per i nomi, possono essere di vari gradi di utilità e rilevanza; gran parte di questo dipende da quali sono gli obiettivi finali dello studente. Il problema più comune che vedo nelle definizioni verbali è la conoscenza presupposta tipicamente sotto forma di gergo.
Supponiamo, ad esempio, che dovessi spiegare che un thread è molto simile a un processo , tranne per il fatto che i thread occupano lo stesso spazio di indirizzi di un determinato processo . A qualcuno che ha già familiarità con i processi e gli spazi di indirizzamento , ho essenzialmente affermato che i thread possono essere associati alla loro comprensione di un processo (cioè possiedono molte delle stesse caratteristiche), ma possono essere differenziati in base a una caratteristica distinta.
Per qualcuno che non possiede quella conoscenza, nel migliore dei casi non ho avuto alcun senso, e nel peggiore dei casi ho fatto sì che lo studente si sentisse inadeguato in qualche modo per non sapere le cose che pensavo dovessero sapere. In tutta onestà, questo è accettabile se i tuoi studenti dovrebbero davvero possedere tali conoscenze (come insegnare a studenti laureati o sviluppatori esperti), ma ritengo che sia un enorme fallimento farlo in qualsiasi materiale di livello introduttivo.
Spesso è molto difficile fornire una buona definizione verbale di un concetto quando è diverso da qualsiasi altra cosa che lo studente abbia mai visto prima. In questo caso, è molto importante che l'insegnante scelga un'analogia che è probabilmente familiare alla persona media, e anche rilevante nella misura in cui trasmette molte delle stesse qualità del concetto.
Ad esempio, è di fondamentale importanza per uno sviluppatore di software capire cosa significa quando entità software (diverse parti di un programma) sono strettamente accoppiate o debolmente accoppiate . Quando si costruisce una casetta da giardino, un giovane falegname potrebbe pensare che sia più veloce e più facile montarla usando i chiodi invece delle viti. Questo è vero fino al punto in cui viene commesso un errore o un cambiamento nel design del capanno da giardino richiede la ricostruzione di parte del capannone.
A questo punto, la decisione di utilizzare i chiodi per unire saldamente le parti della casetta da giardino, ha reso l'intero processo di costruzione più difficile, probabilmente più lento, ed estrarre i chiodi con un martello rischia di danneggiare la struttura. Al contrario, le viti possono richiedere un po' di tempo in più per il montaggio, ma sono facili da rimuovere e presentano pochi rischi di danneggiare le parti vicine del capannone. Questo è ciò che intendo per accoppiato in modo lasco . Naturalmente, ci sono casi in cui hai davvero solo bisogno di un chiodo, ma quella decisione dovrebbe essere guidata dal pensiero critico e dall'esperienza.
Come discuterò in dettaglio più avanti, esistono diversi meccanismi per collegare tra loro parti di un programma che forniscono vari gradi di accoppiamento ; proprio come chiodi e viti . Sebbene la mia analogia possa averti aiutato a capire cosa significa questo termine di fondamentale importanza, non ti ho dato alcuna idea di come applicarlo al di fuori del contesto della costruzione di una casetta da giardino. Questo mi porta al tipo più importante di conoscenza e alla chiave per comprendere in profondità concetti vaghi e difficili in qualsiasi campo di indagine; anche se in questo articolo continueremo a scrivere codice.
Conoscere nel codice
A mio avviso, strettamente per quanto riguarda lo sviluppo del software, la forma più importante per conoscere un concetto deriva dal poterlo utilizzare nel codice dell'applicazione funzionante. Questa forma di conoscenza può essere raggiunta semplicemente scrivendo molto codice e risolvendo molti problemi diversi; non è necessario includere nomi in gergo e definizioni verbali.
Nella mia esperienza, ricordo di aver risolto il problema della comunicazione con un database remoto e un database locale attraverso un'unica interfaccia (saprai presto cosa significa se non lo fai già); piuttosto che il client (qualunque classe comunichi all'interfaccia ) che deve chiamare esplicitamente il database remoto e locale (o anche un database di test). In effetti, il client non aveva idea di cosa ci fosse dietro l'interfaccia, quindi non avevo bisogno di cambiarla indipendentemente dal fatto che fosse in esecuzione in un'app di produzione o in un ambiente di test. Circa un anno dopo aver risolto questo problema, mi sono imbattuto nel termine "Modello di facciata" e non molto tempo dopo il termine "Modello di repository", che sono entrambi nomi che le persone usano per la soluzione descritta in precedenza.
Si spera che tutto questo preambolo illumini alcuni dei difetti che sono più spesso fatti nello spiegare argomenti come l' ereditarietà , le interfacce e le classi astratte . Dei tre, l' ereditarietà è probabilmente la più semplice da usare e da capire. Nella mia esperienza sia come studente di programmazione che come insegnante, gli altri due sono quasi invariabilmente un problema per gli studenti a meno che non venga prestata un'attenzione molto speciale per evitare gli errori discussi in precedenza. Da questo momento in poi, farò del mio meglio per rendere questi argomenti semplici come dovrebbero essere, ma non più semplici.
Una nota sugli esempi
Essendo io stesso il più fluente nello sviluppo di applicazioni mobili Android, userò esempi presi da quella piattaforma in modo da poterti insegnare come creare applicazioni GUI e allo stesso tempo introdurre le funzionalità del linguaggio di Java. Tuttavia, non entrerò così tanto nei dettagli che gli esempi dovrebbero essere incomprensibili da qualcuno con una conoscenza superficiale di Java EE, Swing o JavaFX. Il mio obiettivo finale nella discussione di questi argomenti è aiutarti a capire cosa significano nel contesto della risoluzione di un problema in qualsiasi tipo di applicazione.
Vorrei anche avvertirti, caro lettore, che a volte può sembrare che io sia inutilmente filosofico e pedante riguardo a parole specifiche e alle loro definizioni. La ragione di ciò è che c'è davvero una profonda base filosofica necessaria per comprendere la differenza tra qualcosa che è concreto (reale) e qualcosa che è astratto (meno dettagliato di una cosa reale). Questa comprensione si applica a molte cose al di fuori del campo dell'informatica, ma è particolarmente importante per qualsiasi sviluppatore di software comprendere la natura delle astrazioni . In ogni caso, se le mie parole non ti soddisfano, si spera che gli esempi in codice no.
Ereditarietà e attuazione
Quando si tratta di creare applicazioni con un'interfaccia utente grafica (GUI), l' ereditarietà è probabilmente il meccanismo più importante per rendere possibile la creazione rapida di un'applicazione.
Sebbene vi sia un vantaggio meno compreso nell'utilizzo dell'ereditarietà che verrà discusso in seguito, il vantaggio principale consiste nel condividere l'implementazione tra le classi . Questa parola “attuazione”, almeno ai fini del presente articolo, ha un significato distinto. Per dare una definizione generale della parola in inglese, potrei dire che implementare qualcosa, è renderlo reale .
Per dare una definizione tecnica specifica per lo sviluppo del software, potrei dire che implementare un software significa scrivere righe di codice concrete che soddisfino i requisiti di detto software. Ad esempio, supponiamo che io stia scrivendo un metodo sum : private double sum(double first, double second){
private double sum(double first, double second){ //TODO: implement }
Lo snippet sopra, anche se sono arrivato al punto di scrivere un tipo restituito ( double
) e una dichiarazione di metodo che specifica gli argomenti ( first, second
) e il nome che può essere utilizzato per chiamare detto metodo ( sum
), ha non è stato implementato . Per implementarlo , dobbiamo completare il corpo del metodo in questo modo:
private double sum(double first, double second){ return first + second; }
Naturalmente, il primo esempio non verrebbe compilato, ma vedremo momentaneamente che le interfacce sono un modo in cui possiamo scrivere questo tipo di funzioni non implementate senza errori.
Ereditarietà in Java
Presumibilmente, se stai leggendo questo articolo, hai usato la parola chiave Java extends
almeno una volta. I meccanismi di questa parola chiave sono semplici e il più delle volte descritti utilizzando esempi che hanno a che fare con diversi tipi di animali o forme geometriche; Dog
e Cat
estendono Animal
e così via. Presumo di non aver bisogno di spiegarti la teoria dei tipi rudimentali, quindi entriamo subito nel vantaggio principale dell'ereditarietà in Java, tramite la parola chiave extends
.
Creare un'applicazione "Hello World" basata su console in Java è molto semplice. Supponendo che tu possieda un compilatore Java ( javac ) e un ambiente di runtime ( jre ), puoi scrivere una classe che contiene una funzione principale in questo modo:
public class JavaApp{ public static void main(String []args){ System.out.println("Hello World"); } }
Anche creare un'applicazione GUI in Java su quasi tutte le sue piattaforme principali (Android, Enterprise/Web, Desktop), con un po' di aiuto da un IDE per generare il codice skeleton/boilerplate di una nuova app, è relativamente facile grazie al extends
parola chiave.
Supponiamo di avere un layout XML chiamato activity_main.xml
(in genere costruiamo interfacce utente in modo dichiarativo in Android, tramite file di layout) contenente un TextView
(come un'etichetta di testo) chiamato tvDisplay
:
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:andro android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android: android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" /> </FrameLayout>
Supponiamo inoltre che vorremmo che tvDisplay
dicesse "Hello World!" Per farlo, dobbiamo semplicemente scrivere una classe che usi la parola chiave extends
per ereditare dalla classe Activity
:
import android.app.Activity; import android.os.Bundle; import android.widget.TextView; public class MainActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); ((TextView)findViewById(R.id.tvDisplay)).setText("Hello World"); }
L'effetto dell'ereditarietà dell'implementazione della classe Activity
può essere apprezzato al meglio dando una rapida occhiata al suo codice sorgente. Dubito fortemente che Android sarebbe diventata la piattaforma mobile dominante se fosse stato necessario implementare anche una piccola parte delle oltre 8000 linee necessarie per interagire con il sistema, solo per generare una semplice finestra con del testo. L'ereditarietà è ciò che ci consente di non dover ricostruire da zero il framework Android o qualsiasi piattaforma con cui lavori.
L'ereditarietà può essere utilizzata per l'astrazione
Nella misura in cui può essere utilizzato per condividere l'implementazione tra classi, l' ereditarietà è relativamente semplice da capire. Tuttavia, c'è un altro modo importante in cui è possibile utilizzare l' ereditarietà , che è concettualmente correlato alle interfacce e alle classi astratte di cui parleremo presto.
Per favore, supponiamo per un po' che un'astrazione, usata nel senso più generale, sia una rappresentazione meno dettagliata di una cosa . Invece di qualificarlo con una lunga definizione filosofica, cercherò di sottolineare come funzionano le astrazioni nella vita quotidiana e, poco dopo, le discuterò espressamente in termini di sviluppo del software.
Supponiamo che tu stia viaggiando in Australia e sei consapevole che la regione che stai visitando ospita una densità particolarmente elevata di serpenti taipan interni (apparentemente sono piuttosto velenosi). Decidi di consultare Wikipedia per saperne di più su di loro guardando immagini e altre informazioni. Così facendo, ora sei acutamente consapevole di un particolare tipo di serpente che non hai mai visto prima.
Astrazioni, idee, modelli, o come volete chiamarli, sono rappresentazioni meno dettagliate di una cosa. È importante che siano meno dettagliati rispetto alla realtà perché un vero serpente può morderti; le immagini sulle pagine di Wikipedia in genere non lo fanno. Le astrazioni sono importanti anche perché sia i computer che i cervelli umani hanno una capacità limitata di archiviare, comunicare ed elaborare le informazioni. Avere abbastanza dettagli per utilizzare queste informazioni in modo pratico, senza occupare troppo spazio nella memoria, è ciò che rende possibile sia i computer che i cervelli umani per risolvere i problemi.
Per ricollegare questo all'ereditarietà , tutti e tre gli argomenti principali che sto discutendo qui possono essere usati come astrazioni o meccanismi di astrazione . Supponiamo che nel file di layout della nostra app "Hello World", decidiamo di aggiungere un ImageView
, Button
e ImageButton
:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:andro android:layout_width="match_parent" android:layout_height="match_parent"> <Button android: android:layout_width="wrap_content" android:layout_height="wrap_content"/> <ImageButton android: android:layout_width="wrap_content" android:layout_height="wrap_content"/> <ImageView android: android:layout_width="wrap_content" android:layout_height="wrap_content"/> </LinearLayout>
Supponiamo inoltre che la nostra attività abbia implementato View.OnClickListener
per gestire i clic:
public class MainActivity extends Activity implements View.OnClickListener { private Button b; private ImageButton ib; private ImageView iv; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); //... b = findViewById(R.id.imvDisplay).setOnClickListener(this); ib = findViewById(R.id.btnDisplay).setOnClickListener(this); iv = findViewById(R.id.imbDisplay).setOnClickListener(this); } @Override public void onClick(View view) { final int id = view.getId(); //handle click based on id... } }
Il principio chiave qui è che Button
, ImageButton
e ImageView
ereditano dalla classe View
. Il risultato è che questa funzione onClick
può ricevere eventi di clic da elementi dell'interfaccia utente disparati (sebbene gerarchicamente correlati) facendo riferimento ad essi come alla loro classe padre meno dettagliata. Questo è molto più conveniente che dover scrivere un metodo distinto per gestire ogni tipo di widget sulla piattaforma Android (per non parlare dei widget personalizzati).
Interfacce e astrazione
Potresti aver trovato l'esempio di codice precedente un po' poco interessante, anche se hai capito perché l'ho scelto. Essere in grado di condividere l' implementazione attraverso una gerarchia di classi è incredibilmente utile e direi che è l'utilità principale dell'ereditarietà . Per quanto riguarda il permetterci di trattare un insieme di classi che hanno una classe genitore comune come di tipo uguale (cioè come classe genitore ), quella caratteristica dell'ereditarietà ha un uso limitato.
In modo limitato, sto parlando del requisito delle classi figlie di essere all'interno della stessa gerarchia di classi per essere referenziate tramite o conosciute come la classe genitore. In altre parole, l' ereditarietà è un meccanismo molto restrittivo per l'astrazione . In effetti, se suppongo che l'astrazione sia uno spettro che si muove tra diversi livelli di dettaglio (o informazioni), potrei dire che l' ereditarietà è il meccanismo meno astratto per l'astrazione in Java.

Prima di passare alla discussione delle interfacce , vorrei menzionare che a partire da Java 8 sono state aggiunte alle interfacce due funzioni chiamate Metodi predefiniti e Metodi statici . Alla fine ne parlerò, ma per il momento vorrei che facessimo finta che non esistono. Questo per rendere più semplice spiegare lo scopo principale dell'utilizzo di un'interfaccia , che inizialmente era, e probabilmente è ancora, il meccanismo più astratto per l'astrazione in Java .
Meno dettagli significa più libertà
Nella sezione sull'ereditarietà , ho dato una definizione della parola implementazione , che doveva essere in contrasto con un altro termine in cui entreremo ora. Per essere chiari, non mi interessano le parole stesse, o se sei d'accordo con il loro uso; solo che capisci a cosa puntano concettualmente.
Mentre l' ereditarietà è principalmente uno strumento per condividere l' implementazione tra un insieme di classi, potremmo dire che le interfacce sono principalmente un meccanismo per condividere il comportamento tra un insieme di classi. Il comportamento usato in questo senso è veramente solo una parola non tecnica per metodi astratti . Un metodo astratto è un metodo che, di fatto, non può contenere un corpo del metodo :
public interface OnClickListener { void onClick(View v); }
La reazione naturale per me e per un certo numero di persone che ho istruito, dopo aver prima esaminato un'interfaccia , è stata di chiedermi quale potrebbe essere l'utilità di condividere solo un tipo restituito , un nome di metodo e un elenco di parametri . In superficie, sembra un ottimo modo per creare lavoro extra per te stesso o per chiunque altro potrebbe scrivere la classe che implements
l' interfaccia . La risposta è che le interfacce sono perfette per le situazioni in cui si desidera che un insieme di classi si comporti allo stesso modo (cioè possiedono gli stessi metodi astratti pubblici), ma ci si aspetta che implementino quel comportamento in modi diversi.
Per fare un esempio semplice ma rilevante, la piattaforma Android possiede due classi che si occupano principalmente di creare e gestire parte dell'interfaccia utente: Activity
e Fragment
. Ne consegue che queste classi molto spesso avranno l'esigenza di ascoltare gli eventi che compaiono quando si fa clic su un widget (o si interagisce in altro modo con un utente). Per amor di discussione, prendiamoci un momento per capire perché l' ereditarietà non risolverà quasi mai un problema del genere:
public class OnClickManager { public void onClick(View view){ //Wait a minute... Activities and Fragments almost never //handle click events exactly the same way... } }
Non solo far ereditare le nostre attività e frammenti da OnClickManager
renderebbe impossibile gestire gli eventi in un modo diverso, ma il kicker è che non potremmo nemmeno farlo se lo volessimo. Sia Activity che Fragment estendono già una classe padre e Java non consente più classi padre . Quindi il nostro problema è che vogliamo che un insieme di classi si comporti allo stesso modo, ma dobbiamo avere flessibilità su come la classe implementa quel comportamento . Questo ci riporta al precedente esempio di View.OnClickListener
:
public interface OnClickListener { void onClick(View v); }
Questo è il codice sorgente effettivo (che è nidificato nella classe View
) e queste poche righe ci consentono di garantire un comportamento coerente tra diversi widget ( Views ) e controller dell'interfaccia utente ( Activity, Fragments, ecc . ).
L'astrazione promuove l'accoppiamento allentato
Spero di aver risposto alla domanda generale sul perché le interfacce esistono in Java; tra molte altre lingue. Da un certo punto di vista, sono solo un mezzo per condividere il codice tra le classi, ma sono deliberatamente meno dettagliati per consentire diverse implementazioni . Ma proprio come l' ereditarietà può essere utilizzata sia come meccanismo per la condivisione del codice che per l'astrazione (sebbene con restrizioni sulla gerarchia delle classi), ne consegue che le interfacce forniscono un meccanismo più flessibile per l'astrazione .
In una sezione precedente di questo articolo, ho introdotto l'argomento dell'accoppiamento allentato/stretto per analogia della differenza tra l'uso di chiodi e viti per costruire un qualche tipo di struttura. Per ricapitolare, l'idea di base è che vorrai utilizzare le viti in situazioni in cui è probabile che si verifichino modifiche alla struttura esistente (che possono essere il risultato di errori di fissaggio, modifiche di progettazione e così via). I chiodi vanno bene da usare quando hai solo bisogno di fissare insieme parti della struttura e non sei particolarmente preoccupato di smontarle nel prossimo futuro.
Chiodi e viti devono essere analoghi a riferimenti concreti e astratti (si applica anche il termine dipendenze ) tra classi. Solo così non c'è confusione, il seguente esempio dimostrerà cosa intendo:
class Client { private Validator validator; private INetworkAdapter networkAdapter; void sendNetworkRequest(String input){ if (validator.validateInput(input)) { try { networkAdapter.sendRequest(input); } catch (IOException e){ //handle exception } } } } class Validator { //...validation logic boolean validateInput(String input){ boolean isValid = true; //...change isValid to false based on validation logic return isValid; } } interface INetworkAdapter { //... void sendRequest(String input) throws IOException; }
Qui abbiamo una classe chiamata Client
che possiede due tipi di riferimenti . Si noti che, supponendo che il Client
non abbia nulla a che fare con la creazione dei suoi riferimenti (in realtà non dovrebbe), è disaccoppiato dai dettagli di implementazione di qualsiasi particolare adattatore di rete.
Ci sono alcune importanti implicazioni di questo accoppiamento lento . Per cominciare, posso creare Client
in assoluto isolamento di qualsiasi implementazione di INetworkAdapter
. Immagina per un momento di lavorare in un team di due sviluppatori; uno per costruire il front-end, uno per costruire il back-end. Finché entrambi gli sviluppatori sono tenuti a conoscenza delle interfacce che accoppiano le rispettive classi, possono continuare il lavoro in modo praticamente indipendente l'uno dall'altro.
In secondo luogo, cosa succede se ti dicessi che entrambi gli sviluppatori potrebbero verificare che le rispettive implementazioni funzionino correttamente, anche indipendentemente dai progressi reciproci? Questo è molto facile con le interfacce; basta creare un Test Double che implements
l' interfaccia appropriata:
class FakeNetworkAdapter implements INetworkAdapter { public boolean throwError = false; @Override public void sendRequest(String input) throws IOException { if (throwError) throw new IOException("Test Exception"); } }
In linea di principio, ciò che si può osservare è che lavorare con riferimenti astratti apre le porte a una maggiore modularità, testabilità e alcuni modelli di progettazione molto potenti come Facade Pattern , Observer Pattern e altro ancora. Possono anche consentire agli sviluppatori di trovare un felice equilibrio nella progettazione di diverse parti di un sistema in base al comportamento ( Program To An Interface ), senza impantanarsi nei dettagli di implementazione .
Un ultimo punto sulle astrazioni
Le astrazioni non esistono allo stesso modo di una cosa concreta . Ciò si riflette nel linguaggio di programmazione Java dal fatto che le classi e le interfacce astratte potrebbero non essere istanziate.
Ad esempio, questo sicuramente non compilerebbe:
public class Main extends Application { public static void main(String[] args) { launch(args); } @Override public void start(Stage primaryStage) { //ERROR x2: Foo f = new Foo(); Bar b = new Bar() } private abstract class Foo{} private interface Bar{} }
In effetti, l'idea di aspettarsi che un'interfaccia non implementata o una classe astratta funzionino in fase di esecuzione ha tanto senso quanto aspettarsi che un'uniforme UPS fluttua intorno alla consegna dei pacchetti. Dietro l' astrazione deve esserci qualcosa di concreto perché sia utile; anche se la classe chiamante non ha bisogno di sapere cosa c'è effettivamente dietro i riferimenti astratti .
Classi astratte: mettere tutto insieme
Se sei arrivato così lontano, allora sono felice di dirti che non ho più tangenti filosofiche o frasi gergali da tradurre. In poche parole, le classi astratte sono un meccanismo per condividere l'implementazione e il comportamento in un insieme di classi. Ora, ammetto subito che non mi ritrovo a usare classi astratte così spesso. Ciononostante, la mia speranza è che entro la fine di questa sezione saprete esattamente quando sono richiesti.
Caso di studio del registro di allenamento
Dopo circa un anno dalla creazione di app Android in Java, stavo ricostruendo da zero la mia prima app Android. La prima versione era il tipo di orrenda massa di codice che ti aspetteresti da uno sviluppatore autodidatta con poca guida. Quando ho voluto aggiungere nuove funzionalità, è diventato chiaro che la struttura strettamente accoppiata che avevo costruito esclusivamente con i chiodi , era così impossibile da mantenere che dovevo ricostruirla interamente.
L'app era un registro degli allenamenti progettato per consentire una facile registrazione dei tuoi allenamenti e la possibilità di produrre i dati di un allenamento passato come file di testo o immagine. Senza entrare troppo nei dettagli, ho strutturato i modelli di dati dell'app in modo tale che ci fosse un oggetto Workout
, che comprendeva una raccolta di oggetti Exercise
(tra gli altri campi che sono irrilevanti per questa discussione).
Mentre stavo implementando la funzione per l'output dei dati di allenamento su una sorta di supporto visivo, mi sono reso conto che dovevo affrontare un problema: diversi tipi di esercizi avrebbero richiesto diversi tipi di output di testo.
Per darti un'idea approssimativa, volevo cambiare le uscite a seconda del tipo di esercizio in questo modo:
- Bilanciere: 10 ripetizioni @ 100 libbre
- Manubrio: 10 REPS @ 50 LBS x2
- Peso corporeo: 10 REPS a peso corporeo
- Peso corporeo +: 10 REPS a peso corporeo + 45 libbre
- Temporizzato: 60 SEC @ 100 libbre
Prima di procedere, nota che c'erano altri tipi (l'elaborazione può diventare complicata) e che il codice che mostrerò è stato ridotto e modificato per adattarsi perfettamente a un articolo.
In linea con la mia definizione di prima, l'obiettivo di scrivere una classe astratta è implementare tutto (anche stato come variabili e costanti ) che è condiviso tra tutte le classi figlie nella classe astratta . Quindi, per tutto ciò che cambia in dette classi figlio , crea un metodo astratto :
abstract class Exercise { private final String type; protected final String name; protected final int[] repetitionsOrTime; protected final double[] weight; protected static final String POUNDS = "LBS"; protected static final String SECONDS = "SEC "; protected static final String REPETITIONS = "REPS "; public Exercise(String type, String name, int[] repetitionsOrTime, double[] weight) { this.type = type; this.name = name; this.repetitionsOrTime = repetitionsOrTime; this.weight = weight; } public String getFormattedOutput(){ StringBuilder sb = new StringBuilder(); sb.append(name); sb.append("\n"); getSetData(sb); sb.append("\n"); return sb.toString(); } /** * Append data appropriately based on Exercise type * @param sb - StringBuilder to Append data to */ protected abstract void getSetData(StringBuilder sb); //...Getters }
Potrei affermare l'ovvio, ma se hai domande su cosa dovrebbe o non dovrebbe essere implementato nella classe astratta , la chiave è guardare qualsiasi parte dell'implementazione che è stata ripetuta in tutte le classi figlio.
Ora che abbiamo stabilito cosa è comune a tutti gli esercizi, possiamo iniziare a creare classi figlio con specializzazioni per ogni tipo di output di String:
Esercizio con bilanciere:
class BarbellExercise extends Exercise { public BarbellExercise(String type, String name, int[] repetitionsOrTime, double[] weight) { super(type, name, repetitionsOrTime, weight); } @Override protected void getSetData(StringBuilder sb) { for (int i = 0; i < repetitionsOrTime.length; i++) { sb.append(repetitionsOrTime[i]); sb.append(" "); sb.append(REPETITIONS); sb.append(" @ "); sb.append(weight[i]); sb.append(POUNDS); sb.append("\n"); } } }
Esercizio con i manubri:
class DumbbellExercise extends Exercise { private static final String TIMES_TWO = "x2"; public DumbbellExercise(String type, String name, int[] repetitionsOrTime, double[] weight) { super(type, name, repetitionsOrTime, weight); } @Override protected void getSetData(StringBuilder sb) { for (int i = 0; i < repetitionsOrTime.length; i++) { sb.append(repetitionsOrTime[i]); sb.append(" "); sb.append(REPETITIONS); sb.append(" @ "); sb.append(weight[i]); sb.append(POUNDS); sb.append(TIMES_TWO); sb.append("\n"); } } }
Esercizio a corpo libero:
class BodyweightExercise extends Exercise { private static final String BODYWEIGHT = "Bodyweight"; public BodyweightExercise(String type, String name, int[] repetitionsOrTime, double[] weight) { super(type, name, repetitionsOrTime, weight); } @Override protected void getSetData(StringBuilder sb) { for (int i = 0; i < repetitionsOrTime.length; i++) { sb.append(repetitionsOrTime[i]); sb.append(" "); sb.append(REPETITIONS); sb.append(" @ "); sb.append(BODYWEIGHT); sb.append("\n"); } } }
Sono certo che alcuni lettori astuti troveranno cose che avrebbero potuto essere astratte in modo più efficiente, ma lo scopo di questo esempio (che è stato semplificato dalla fonte originale) è quello di dimostrare l'approccio generale. Naturalmente, nessun articolo di programmazione sarebbe completo senza qualcosa che può essere eseguito. Esistono diversi compilatori Java online che puoi utilizzare per eseguire questo codice se desideri testarlo (a meno che tu non abbia già un IDE):
public class Main { public static void main(String[] args) { //Note: I actually used another nested class called a "Set" instead of an Array //to represent each Set of an Exercise. int[] reps = {10, 10, 8}; double[] weight = {70.0, 70.0, 70.0}; Exercise e1 = new BarbellExercise( "Barbell", "Barbell Bench Press", reps, weight ); Exercise e2 = new DumbbellExercise( "Dumbbell", "Dumbbell Bench Press", reps, weight ); Exercise e3 = new BodyweightExercise( "Bodyweight", "Push Up", reps, weight ); System.out.println( e1.getFormattedOutput() + e2.getFormattedOutput() + e3.getFormattedOutput() ); } }
Executing this toy application yields the following output: Barbell Bench Press
10 REPS @ 70.0LBS 10 REPS @ 70.0LBS 8 REPS @ 70.0LBS Dumbbell Bench Press 10 REPS @ 70.0LBSx2 10 REPS @ 70.0LBSx2 8 REPS @ 70.0LBSx2 Push Up 10 REPS @ Bodyweight 10 REPS @ Bodyweight 8 REPS @ Bodyweight
Further Considerations
Earlier, I mentioned that there are two features of Java interfaces (as of Java 8) which are decidedly geared towards sharing implementation , as opposed to behavior . These features are known as Default Methods and Static Methods .
I have decided not to go into detail on these features for the reason that they are most typically used in mature and/or large code bases where a given interface has many inheritors. Despite the fact that this is meant to be an introductory article, and I still encourage you to take a look at these features eventually, even though I am confident that you will not need to worry about them just yet.
I would also like to mention that there are other ways to share implementation across a set of classes (or even static methods ) in a Java application that does not require inheritance or abstraction at all. For example, suppose you have some implementation which you expect to use in a variety of different classes, but does not necessarily make sense to share via inheritance . A common pattern in Java is to write what is known as a Utility class, which is a simple class
containing the requisite implementation in a static method :
public class TimeConverterUtil { /** * Accepts an hour (0-23) and minute (0-59), then attempts to format them into an appropriate * format such as 12, 30 -> 12:30 pm */ public static String convertTime (int hour, int minute){ String unformattedTime = Integer.toString(hour) + ":" + Integer.toString(minute); DateFormat f1 = new SimpleDateFormat("HH:mm"); Date d = null; try { d = f1.parse(unformattedTime); } catch (ParseException e) { e.printStackTrace(); } DateFormat f2 = new SimpleDateFormat("h:mm a"); return f2.format(d).toLowerCase(); } }
Using this static method in an external class (or another static method ) looks like this:
public class Main { public static void main(String[] args){ //... String time = TimeConverterUtil.convertTime(12, 30); //... } }
Cheat Sheet
We have covered a lot of ground in this article, so I would like to spend a moment summarizing the three main mechanisms based on what problems they solve. Since you should possess a sufficient understanding of the terms and ideas I have either introduced or redefined for the purposes of this article, I will keep the summaries brief.
I Want A Set Of Child Classes To Share Implementation
Classic inheritance , which requires a child class to inherit from a parent class , is a very simple mechanism for sharing implementation across a set of classes. An easy way to decide if some implementation should be pulled into a parent class , is to see whether it is repeated in a number of different classes line for line. The acronym DRY ( Don't Repeat Yourself ) is a good mnemonic device to watch out for this situation.
While coupling child classes together with a common parent class can present some limitations, a side benefit is that they can all be referenced as the parent class , which provides a limited degree of abstraction .
I Want A Set Of Classes To Share Behavior
Sometimes, you want a set of classes to be capable of possessing certain abstract methods (referred to as behavior ), but you do not expect the implementation of that behavior to be repeated across inheritors.
By definition, Java interfaces may not contain any implementation (except for Default and Static Methods ), but any class which implements an interface , must supply an implementation for all abstract methods, otherwise, the code will not compile. This provides a healthy measure of flexibility and restriction on what is actually shared and does not require the inheritors to be of the same class hierarchy .
I Want A Set Of Child Classes To Share Behavior And Implementation
Although I do not find myself using abstract classes all over the place, they are perfect for situations when you require a mechanism for sharing both behavior and implementation across a set of classes. Anything which will be repeated across inheritors may be implemented directly in the abstract class
, and anything which requires flexibility may be specified as an abstract method .