Stăpânirea OOP: Un ghid practic pentru moștenire, interfețe și clase abstracte
Publicat: 2022-03-10Din câte îmi pot da seama, este neobișnuit să întâlnim conținut educațional în domeniul dezvoltării software, care oferă un amestec adecvat de informații teoretice și practice. Dacă ar fi să ghicesc de ce, presupun că este pentru că indivizii care se concentrează pe teorie tind să se apuce de predare, iar cei care se concentrează pe informații practice tind să fie plătiți pentru a rezolva probleme specifice, folosind limbaje și instrumente specifice.
Aceasta este, desigur, o generalizare largă, dar dacă o acceptăm pe scurt de dragul argumentelor, rezultă că mulți oameni (nu în niciun caz toți oamenii) care își asumă rolul de profesor tind să fie fie săraci, fie complet incapabili. de a explica cunoștințele practice relevante pentru un anumit concept.
În acest articol, voi face tot posibilul să discut trei mecanisme de bază pe care le veți găsi în majoritatea limbajelor de programare orientată pe obiecte (OOP): Moștenire , interfețe (alias protocoale ) și clase abstracte . În loc să vă ofer explicații verbale tehnice și complexe despre ce este fiecare mecanism, voi face tot posibilul să mă concentrez pe ceea ce fac și când să le folosesc.
Cu toate acestea, înainte de a le aborda individual, aș dori să discut pe scurt ce înseamnă să oferi o explicație teoretic solidă, dar practic inutilă. Speranța mea este că puteți folosi aceste informații pentru a vă ajuta să căutați diferite resurse educaționale și pentru a evita să vă învinovățiți atunci când lucrurile nu au sens.
Diferite grade de cunoaștere
Cunoașterea numelor
Cunoașterea numelui a ceva este, fără îndoială, cea mai superficială formă de cunoaștere. De fapt, un nume este util în general doar în măsura în care fie este folosit în mod obișnuit de mulți oameni pentru a se referi la același lucru și/sau ajută la descrierea aceluiași lucru. Din păcate, după cum a descoperit oricine care a petrecut timp în acest domeniu, mulți oameni folosesc nume diferite pentru același lucru (de exemplu interfețe și protocoale ), aceleași nume pentru lucruri diferite (de exemplu module și componente ) sau nume care sunt ezoterice pentru punctul de a fi absurd (ex. Fie Monada ). În cele din urmă, numele sunt doar indicii (sau referințe) la modele mentale și pot fi de diferite grade de utilitate.
Pentru a face acest domeniu și mai dificil de studiat, aș risca să presupunem că pentru majoritatea indivizilor, scrierea codului este (sau cel puțin a fost) o experiență foarte unică. Și mai complicată este înțelegerea modului în care acel cod este în cele din urmă compilat în limbajul mașinii și reprezentat în realitatea fizică ca o serie de impulsuri electrice care se schimbă în timp. Chiar dacă cineva poate aminti denumirile proceselor, conceptelor și mecanismelor care sunt folosite într-un program, nu există nicio garanție că modelele mentale pe care le creează pentru astfel de lucruri sunt în concordanță cu modelele altui individ; darămite dacă sunt exacte obiectiv.
Din aceste motive, pe lângă faptul că nu am o memorie bună în mod natural pentru jargon, consider că numele sunt cel mai puțin important aspect al cunoașterii ceva. Asta nu înseamnă că numele sunt inutile, dar în trecut am învățat și am folosit multe modele de design în proiectele mele, doar pentru a afla despre numele folosit în mod obișnuit luni sau chiar ani mai târziu.
Cunoașterea definițiilor verbale și a analogiilor
Definițiile verbale sunt punctul de plecare natural pentru descrierea unui nou concept. Cu toate acestea, ca și în cazul numelor, ele pot avea diferite grade de utilitate și relevanță; mare parte din asta în funcție de obiectivele finale ale cursantului. Cea mai frecventă problemă pe care o văd în definițiile verbale este cunoștințele presupuse de obicei sub formă de jargon.
Să presupunem, de exemplu, că trebuie să explic că un fir de execuție seamănă foarte mult cu un proces , cu excepția faptului că firele de execuție ocupă același spațiu de adresă al unui proces dat. Cuiva care este deja familiarizat cu procesele și spațiile de adresă , i-am spus în esență că firele de execuție pot fi asociate cu înțelegerea lor asupra unui proces (adică posedă multe dintre aceleași caracteristici), dar pot fi diferențiate pe baza unei caracteristici distincte.
Pentru cineva care nu posedă aceste cunoștințe, în cel mai bun caz nu am avut niciun sens și, în cel mai rău caz, am făcut ca cursantul să se simtă inadecvat într-un fel pentru că nu cunoaște lucrurile pe care am presupus că ar trebui să le cunoască. Pentru a fi corect, acest lucru este acceptabil dacă cursanții dvs. ar trebui să posede într-adevăr astfel de cunoștințe (cum ar fi predarea studenților absolvenți sau dezvoltatori cu experiență), dar consider că este un eșec monumental să faceți acest lucru în orice material de nivel introductiv.
Adesea, este foarte dificil să oferi o definiție verbală bună a unui concept atunci când acesta nu seamănă cu orice altceva a mai văzut cursantul. În acest caz, este foarte important ca profesorul să aleagă o analogie care poate fi familiară persoanei obișnuite și, de asemenea, relevantă în măsura în care transmite multe dintre aceleași calități ale conceptului.
De exemplu, este foarte important ca un dezvoltator de software să înțeleagă ce înseamnă atunci când entitățile software (diferitele părți ale unui program) sunt strâns cuplate sau slab cuplate . Atunci când construiește un șopron de grădină, un tâmplar junior poate crede că este mai rapid și mai ușor să îl montezi folosind cuie în loc de șuruburi. Acest lucru este valabil până în momentul în care se comite o greșeală sau o schimbare în designul șopronului de grădină necesită reconstruirea unei părți a șopronului.
În acest moment, decizia de a folosi cuie pentru a cupla strâns părțile șopronului de grădină, a făcut ca procesul de construcție în ansamblu să fie mai dificil, probabil mai lent, iar extragerea cuielor cu un ciocan riscă deteriorarea structurii. În schimb, șuruburile pot dura puțin timp pentru asamblare, dar sunt ușor de îndepărtat și prezintă un risc mic de a deteriora părțile din apropiere ale șopronului. Aceasta este ceea ce vreau să spun prin cuplare liberă . Desigur, există cazuri în care într-adevăr aveți nevoie doar de un cui, dar această decizie ar trebui să fie ghidată de gândire critică și experiență.
După cum voi discuta în detaliu mai târziu, există diferite mecanisme pentru conectarea părților unui program împreună, care oferă grade diferite de cuplare ; la fel ca cuiele și șuruburile . Deși analogia mea v-a ajutat să înțelegeți ce înseamnă acest termen extrem de important, nu v-am dat nicio idee despre cum să îl aplicați în afara contextului construirii unei șoproni de grădină. Acest lucru mă conduce la cel mai important tip de cunoaștere și la cheia înțelegerii profunde a conceptelor vagi și dificile în orice domeniu de cercetare; deși ne vom menține să scriem cod în acest articol.
Cunoașterea În Cod
În opinia mea, strict în ceea ce privește dezvoltarea de software, cea mai importantă formă de cunoaștere a unui concept vine din a-l putea folosi în codul aplicației de lucru. Această formă de cunoaștere poate fi obținută pur și simplu prin scrierea multor coduri și prin rezolvarea multor probleme diferite; Numele jargonului și definițiile verbale nu trebuie incluse.
Din propria mea experiență, îmi amintesc că am rezolvat problema comunicării cu o bază de date la distanță și o bază de date locală printr-o singură interfață (veți ști ce înseamnă asta în curând dacă nu o faceți deja); mai degrabă decât clientul (indiferent de clasă care vorbește cu interfața ) care trebuie să apeleze în mod explicit la distanță și localul (sau chiar o bază de date de testare). De fapt, clientul habar n-avea ce se află în spatele interfeței, așa că nu trebuia să o schimb, indiferent dacă rula într-o aplicație de producție sau într-un mediu de testare. La aproximativ un an după ce am rezolvat această problemă, am dat peste termenul „Facade Pattern” și nu la mult timp după termenul „Repository Pattern”, care sunt ambele nume pe care oamenii le folosesc pentru soluția descrisă anterior.
Tot acest preambul este să lumineze, sperăm, unele dintre defectele care apar cel mai adesea în explicarea unor subiecte precum moștenirea , interfețele și clasele abstracte . Dintre cele trei, moștenirea este probabil cea mai simplă de utilizat și de înțeles. Din experiența mea, atât ca student la programare, cât și ca profesor, celelalte două reprezintă aproape invariabil o problemă pentru cursanți, cu excepția cazului în care se acordă o atenție deosebită evitării greșelilor discutate mai devreme. Din acest moment, voi face tot posibilul pentru a face aceste subiecte atât de simple pe cât ar trebui să fie, dar nu mai simple.
O notă despre exemple
Fiind cel mai fluent în dezvoltarea de aplicații mobile Android, voi folosi exemple preluate de pe platforma respectivă, astfel încât să vă pot învăța despre construirea aplicațiilor GUI în același timp cu introducerea caracteristicilor de limbaj ale Java. Cu toate acestea, nu voi intra atât de multe detalii încât exemplele ar trebui să fie de neînțeles de către cineva cu o înțelegere superficială a Java EE, Swing sau JavaFX. Scopul meu final în discutarea acestor subiecte este să vă ajut să înțelegeți ce înseamnă ele în contextul rezolvării unei probleme în aproape orice fel de aplicație.
De asemenea, aș vrea să vă avertizez, dragă cititor, că uneori s-ar putea părea că sunt inutil filozofic și pedant cu privire la anumite cuvinte și definițiile lor. Motivul pentru aceasta este că există cu adevărat o bază filozofică profundă necesară pentru a înțelege diferența dintre ceva care este concret (real) și ceva care este abstract (mai puțin detaliat decât un lucru real). Această înțelegere se aplică multor lucruri în afara domeniului de calcul, dar este de o importanță deosebită pentru orice dezvoltator de software să înțeleagă natura abstracțiunilor . În orice caz, dacă cuvintele mele nu vă vor da greș, exemplele din cod sperăm că nu.
Moștenirea și implementarea
Când vine vorba de construirea de aplicații cu o interfață grafică cu utilizatorul (GUI), moștenirea este, probabil, cel mai important mecanism pentru a face posibilă construirea rapidă a unei aplicații.
Deși există un beneficiu mai puțin înțeles în utilizarea moștenirii , care va fi discutat mai târziu, beneficiul principal este împărțirea implementării între clase . Acest cuvânt „implementare”, cel puțin în sensul acestui articol, are un sens distinct. Pentru a da o definiție generală a cuvântului în engleză, aș putea spune că a implementa ceva înseamnă a -l face real .
Pentru a da o definiție tehnică specifică dezvoltării software, aș putea spune că a implementa o bucată de software înseamnă a scrie linii concrete de cod care să satisfacă cerințele respectivei piese de software. De exemplu, să presupunem că scriu o metodă de sumă : private double sum(double first, double second){
private double sum(double first, double second){ //TODO: implement }
Fragmentul de mai sus, deși am ajuns până la scrierea unui tip de returnare ( double
) și a unei declarații de metodă care specifică argumentele ( first, second
) și numele care poate fi folosit pentru a apela metoda respectivă ( sum
), are nu a fost implementat . Pentru a o implementa , trebuie să completăm corpul metodei astfel:
private double sum(double first, double second){ return first + second; }
Desigur, primul exemplu nu s-ar compila, dar vom vedea momentan că interfețele sunt o modalitate prin care putem scrie astfel de funcții neimplementate fără erori.
Moștenirea în Java
Probabil, dacă citiți acest articol, ați folosit cel puțin o dată cuvântul cheie extends
Java. Mecanica acestui cuvânt cheie este simplă și cel mai adesea descrisă folosind exemple de diferite tipuri de animale sau forme geometrice; Dog
și Cat
extind Animal
și așa mai departe. Voi presupune că nu trebuie să vă explic teoria tipurilor rudimentare, așa că haideți să intrăm direct în beneficiul principal al moștenirii în Java, prin cuvântul cheie extends
.
Construirea unei aplicații „Hello World” bazată pe consolă în Java este foarte simplă. Presupunând că aveți un compilator Java ( javac ) și un mediu de rulare ( jre ), puteți scrie o clasă care conține o funcție principală astfel:
public class JavaApp{ public static void main(String []args){ System.out.println("Hello World"); } }
Construirea unei aplicații GUI în Java pe aproape oricare dintre platformele sale principale (Android, Enterprise/Web, Desktop), cu puțin ajutor din partea unui IDE pentru a genera codul schelet/boilerplate al unei noi aplicații, este, de asemenea, relativ ușoară datorită extends
cuvântul cheie.
Să presupunem că avem un XML Layout numit activity_main.xml
(de obicei construim interfețe cu utilizatorul declarativ în Android, prin fișiere Layout) care conține un TextView
(cum ar fi o etichetă de text) numit 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>
De asemenea, să presupunem că am dori ca tvDisplay
să spună „Bună lume!” Pentru a face acest lucru, trebuie pur și simplu să scriem o clasă care folosește cuvântul cheie extends
pentru a moșteni din clasa 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"); }
Efectul moștenirii implementării clasei Activity
poate fi cel mai bine apreciat aruncând o privire rapidă la codul sursă. Mă îndoiesc foarte mult că Android ar fi devenit platforma mobilă dominantă dacă ar fi fost nevoie să implementăm chiar și o mică parte din cele peste 8000 de linii necesare pentru a interacționa cu sistemul, doar pentru a genera o fereastră simplă cu ceva text. Moștenirea este ceea ce ne permite să nu trebuie să reconstruim de la zero cadrul Android sau orice platformă cu care lucrați.
Moștenirea poate fi folosită pentru abstracție
În măsura în care poate fi folosit pentru a partaja implementarea între clase, moștenirea este relativ simplu de înțeles. Cu toate acestea, există un alt mod important în care poate fi utilizată moștenirea , care este legată conceptual de interfețele și clasele abstracte despre care vom discuta în curând.
Dacă vă rog, presupuneți pentru un timp următor că o abstractizare, folosită în sensul cel mai general, este o reprezentare mai puțin detaliată a unui lucru . În loc să calific asta cu o definiție filozofică lungă, voi încerca să subliniez cum funcționează abstracțiile în viața de zi cu zi și, la scurt timp după aceea, le voi discuta în mod expres în ceea ce privește dezvoltarea software-ului.
Să presupunem că călătoriți în Australia și știți că regiunea pe care o vizitați găzduiește o densitate deosebit de mare de șerpi taipan din interior (se pare că sunt destul de otrăvitori). Decizi să consulți Wikipedia pentru a afla mai multe despre ele uitându-te la imagini și alte informații. Procedând astfel, acum ești foarte conștient de un anumit tip de șarpe pe care nu l-ai mai văzut până acum.
Abstracțiile, ideile, modelele sau orice altceva doriți să le numiți, sunt reprezentări mai puțin detaliate ale unui lucru. Este important ca acestea să fie mai puțin detaliate decât cele reale, deoarece un șarpe adevărat te poate mușca; imaginile de pe paginile Wikipedia de obicei nu. Abstracțiile sunt, de asemenea, importante deoarece atât computerele, cât și creierul uman au o capacitate limitată de a stoca, comunica și procesa informații. A avea suficiente detalii pentru a utiliza aceste informații într-un mod practic, fără a ocupa prea mult spațiu în memorie, este ceea ce face posibil ca computerele și creierul uman deopotrivă să rezolve probleme.
Pentru a lega acest lucru înapoi la moștenire , toate cele trei subiecte principale pe care le discut aici pot fi folosite ca abstracții sau mecanisme de abstractizare . Să presupunem că în fișierul de aspect al aplicației noastre „Hello World”, decidem să adăugăm un ImageView
, Button
și 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>
De asemenea, să presupunem că Activitatea noastră a implementat View.OnClickListener
pentru a gestiona clicurile:
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... } }
Principiul cheie aici este că Button
, ImageButton
și ImageView
moștenesc din clasa View
. Rezultatul este că această funcție onClick
poate primi evenimente de clic de la elemente UI disparate (deși legate ierarhic) prin referirea la ele ca clasa lor părinte mai puțin detaliată. Acest lucru este mult mai convenabil decât a trebui să scrieți o metodă distinctă pentru a gestiona orice fel de widget pe platforma Android (să nu mai vorbim de widget-uri personalizate).
Interfețe și abstracție
S-ar putea să fi considerat că exemplul de cod anterior este puțin neinspirator, chiar dacă ați înțeles de ce l-am ales. Posibilitatea de a partaja implementarea într-o ierarhie de clase este incredibil de utilă și aș susține că aceasta este utilitatea principală a moștenirii . În ceea ce privește faptul că ne permite să tratăm un set de clase care au o clasă părinte comună ca tip egal (adică ca clasa părinte ), acea caracteristică a moștenirii are o utilizare limitată.
Prin limitare, vorbesc despre cerința ca clasele copil să fie în aceeași ierarhie de clasă pentru a fi referite prin, sau cunoscute ca clasa părinte. Cu alte cuvinte, moștenirea este un mecanism foarte restrictiv pentru abstractizare . De fapt, dacă presupun că abstracția este un spectru care se mișcă între diferite niveluri de detaliu (sau informații), aș putea spune că moștenirea este cel mai puțin abstract mecanism de abstractizare în Java.
Înainte de a trece la discutarea interfețelor , aș dori să menționez că, începând cu Java 8 , două caracteristici numite Metode implicite și Metode statice au fost adăugate la interfețe . Le voi discuta până la urmă, dar pentru moment aș vrea să ne prefacem că nu există. Acest lucru este pentru a facilita explicarea scopului principal al utilizării unei interfețe , care a fost inițial și, probabil, este încă, cel mai abstract mecanism de abstractizare în Java .
Mai puține detalii înseamnă mai multă libertate
În secțiunea despre moștenire , am dat o definiție a cuvântului implementare , care era menită să contrasteze cu un alt termen în care vom intra acum. Pentru a fi clar, nu-mi pasă de cuvintele în sine sau dacă sunteți de acord cu utilizarea lor; doar că înțelegeți la ce indică ele conceptual.
În timp ce moștenirea este în primul rând un instrument de partajare a implementării într-un set de clase, am putea spune că interfețele sunt în primul rând un mecanism de partajare a comportamentului într-un set de clase. Comportamentul folosit în acest sens este cu adevărat doar un cuvânt non-tehnic pentru metode abstracte . O metodă abstractă este o metodă care, de fapt, nu poate conține un corp de metodă :
public interface OnClickListener { void onClick(View v); }
Reacția naturală pentru mine și pentru un număr de persoane pe care le-am îndrumat, după ce am privit mai întâi o interfață , a fost să mă întreb care ar fi utilitatea de a partaja doar un tip de returnare , un nume de metodă și o listă de parametri . La suprafață, pare o modalitate excelentă de a crea muncă suplimentară pentru tine sau pentru oricine altcineva ar putea scrie clasa care implements
interfața . Răspunsul este că interfețele sunt perfecte pentru situațiile în care doriți ca un set de clase să se comporte în același mod (adică posedă aceleași metode abstracte publice), dar vă așteptați să implementeze acel comportament în moduri diferite.
Pentru a lua un exemplu simplu, dar relevant, platforma Android posedă două clase care sunt în principal în domeniul creării și gestionării unei părți a interfeței cu utilizatorul: Activity
și Fragment
. Rezultă că aceste clase vor avea de foarte multe ori cerința de a asculta evenimentele care apar atunci când se face clic pe un widget (sau interacționează în alt mod de către un utilizator). De dragul argumentelor, să luăm un moment pentru a înțelege de ce moștenirea nu va rezolva aproape niciodată o astfel de problemă:
public class OnClickManager { public void onClick(View view){ //Wait a minute... Activities and Fragments almost never //handle click events exactly the same way... } }
Nu doar ca Activitățile și Fragmentele noastre să moștenească de la OnClickManager
ar face imposibilă gestionarea evenimentelor într-un mod diferit, dar ea este că nici măcar nu am putea face asta dacă am dori. Atât Activity cât și Fragment extind deja o clasă părinte , iar Java nu permite mai multe clase părinte . Deci problema noastră este că vrem ca un set de clase să se comporte la fel, dar trebuie să avem flexibilitate în ceea ce privește modul în care clasa implementează acel comportament . Acest lucru ne aduce înapoi la exemplul anterior al View.OnClickListener
:
public interface OnClickListener { void onClick(View v); }
Acesta este codul sursă real (care este imbricat în clasa View
), iar aceste câteva linii ne permit să asigurăm un comportament consistent în diferite widget-uri ( Vizualizări ) și controlere UI ( Activități, Fragmente etc. ).
Abstracția promovează cuplarea liberă
Sper că am răspuns la întrebarea generală despre de ce există interfețe în Java; printre multe alte limbi. Dintr-o perspectivă, acestea sunt doar un mijloc de partajare a codului între clase, dar sunt în mod deliberat mai puțin detaliate pentru a permite implementări diferite. Dar la fel cum moștenirea poate fi folosită atât ca mecanism de partajare a codului, cât și de abstracție (deși cu restricții asupra ierarhiei claselor), rezultă că interfețele oferă un mecanism mai flexibil pentru abstracție .
Într-o secțiune anterioară a acestui articol, am introdus subiectul cuplajului liber/strâns prin analogie cu diferența dintre utilizarea cuielor și șuruburilor pentru a construi un fel de structură. Pentru a recapitula, ideea de bază este că veți dori să utilizați șuruburi în situațiile în care este probabil să se întâmple modificarea structurii existente (care poate fi rezultatul remedierii greșelilor, modificărilor de proiectare și așa mai departe). Unghiile sunt bine de folosit atunci când trebuie doar să fixați părți ale structurii împreună și nu vă faceți griji să le demontați în viitorul apropiat.
Cuie și șuruburi sunt menite să fie analoage referințelor concrete și abstracte (se aplică și termenul de dependențe ) între clase. Pentru a nu exista confuzii, următorul exemplu va demonstra ce vreau să spun:
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; }
Aici, avem o clasă numită Client
care posedă două tipuri de referințe . Observați că, presupunând că Client
nu are nimic de-a face cu crearea referințelor sale (chiar nu ar trebui), este decuplat de detaliile de implementare ale oricărui adaptor de rețea anume.
Există câteva implicații importante ale acestui cuplaj liber . Pentru început, pot construi Client
în izolare absolută a oricărei implementări a INetworkAdapter
. Imaginați-vă pentru un moment că lucrați într-o echipă de doi dezvoltatori; unul pentru a construi partea din față, unul pentru a construi partea din spate. Atâta timp cât ambii dezvoltatori sunt ținuți la curent cu interfețele care le cuplează clasele respective, ei pot continua munca practic independent unul de celălalt.
În al doilea rând, dacă v-aș spune că ambii dezvoltatori ar putea verifica dacă implementările lor respective au funcționat corect, de asemenea, independent de progresul celuilalt? Acest lucru este foarte ușor cu interfețele; doar construiți un Test Double care implements
interfața corespunzătoare:
class FakeNetworkAdapter implements INetworkAdapter { public boolean throwError = false; @Override public void sendRequest(String input) throws IOException { if (throwError) throw new IOException("Test Exception"); } }
În principiu, ceea ce se poate observa este că lucrul cu referințe abstracte deschide ușa către modularitate sporită, testabilitate și unele modele de design foarte puternice, cum ar fi Facade Pattern , Observer Pattern și multe altele. De asemenea, pot permite dezvoltatorilor să găsească un echilibru fericit în proiectarea diferitelor părți ale unui sistem bazat pe comportament ( Program To An Interface ), fără a se bloca în detaliile de implementare .
Un punct final despre abstracții
Abstracțiile nu există în același mod ca un lucru concret . Acest lucru se reflectă în limbajul de programare Java prin faptul că clasele și interfețele abstracte nu pot fi instanțiate.
De exemplu, acest lucru cu siguranță nu ar compila:
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{} }
De fapt, ideea de a aștepta ca o interfață neimplementată sau o clasă abstractă să funcționeze în timpul execuției are la fel de mult sens ca și a aștepta ca o uniformă UPS să plutească în jurul livrării de pachete. Ceva concret trebuie să se afle în spatele abstracției pentru ca aceasta să fie de utilitate; chiar dacă clasa apelantă nu are nevoie să știe ce se află de fapt în spatele referințelor abstracte .
Clasele de abstracte: punerea totul împreună
Dacă ați ajuns până aici, atunci sunt bucuros să vă spun că nu mai am tangente filozofice sau expresii de jargon de tradus. Mai simplu spus, clasele abstracte sunt un mecanism pentru împărtășirea implementării și a comportamentului într-un set de clase. Acum, voi recunoaște imediat că nu mă trezesc să folosesc clase abstracte atât de des. Chiar și așa, speranța mea este că până la sfârșitul acestei secțiuni veți ști exact când sunt solicitate.
Jurnal de antrenament Studiu de caz
La aproximativ un an de la construirea aplicațiilor Android în Java, reconstruiam prima mea aplicație Android de la zero. Prima versiune a fost genul de masă îngrozitoare de cod la care v-ați aștepta de la un dezvoltator autodidact cu puțină îndrumare. Până când am vrut să adaug o nouă funcționalitate, a devenit clar că structura strâns cuplată pe care o construisem exclusiv cu cuie era atât de imposibil de întreținut încât trebuie să o reconstruiesc în întregime.
Aplicația a fost un jurnal de antrenament care a fost conceput pentru a permite înregistrarea ușoară a antrenamentelor și capacitatea de a scoate datele unui antrenament trecut ca fișier text sau imagine. Fără a intra în prea multe detalii, am structurat modelele de date ale aplicației astfel încât să existe un obiect Workout
, care cuprindea o colecție de obiecte Exercise
(printre alte câmpuri care sunt irelevante pentru această discuție).
În timp ce implementam caracteristica pentru a trimite date de antrenament pe un fel de mediu vizual, mi-am dat seama că trebuie să mă confrunt cu o problemă: diferite tipuri de exerciții ar necesita diferite tipuri de ieșiri de text.
Pentru a vă face o idee aproximativă, am vrut să schimb rezultatele în funcție de tipul de exercițiu astfel:
- Barbell: 10 REPS @ 100 LBS
- Gantera: 10 REPS @ 50 LBS x2
- Greutate corporală: 10 REPS @ greutate corporală
- Greutate corporală +: 10 REPEȚI @ Greutate corporală + 45 LBS
- Cronometrat: 60 SEC @ 100 LBS
Înainte de a continua, rețineți că au existat alte tipuri (se poate deveni complicat) și că codul pe care îl voi afișa a fost tăiat și modificat pentru a se potrivi bine într-un articol.
În conformitate cu definiția mea de mai înainte, scopul scrierii unei clase abstracte este de a implementa tot ceea ce (chiar și starea , cum ar fi variabilele și constantele ) care este partajat în toate clasele copil din clasa abstractă . Apoi, pentru orice se schimbă între clasele copil menționate, creați o metodă abstractă :
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 }
S-ar putea să spun ceea ce este evident, dar dacă aveți întrebări despre ce ar trebui sau nu ar trebui implementat în clasa abstractă , cheia este să vă uitați la orice parte a implementării care a fost repetată în toate clasele de copii.
Acum că am stabilit ce este comun între toate exercițiile, putem începe să creăm clase copii cu specializări pentru fiecare tip de ieșire String:
Exercițiu cu mreană:
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"); } } }
Exercițiu cu gantere:
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"); } } }
Exerciții cu greutatea corporală:
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"); } } }
Sunt sigur că unii cititori pricepuți vor găsi lucruri care ar fi putut fi extrase într-un mod mai eficient, dar scopul acestui exemplu (care a fost simplificat din sursa originală) este de a demonstra abordarea generală. Desigur, niciun articol de programare nu ar fi complet fără ceva ce poate fi executat. Există mai multe compilatoare Java online pe care le puteți folosi pentru a rula acest cod dacă doriți să-l testați (cu excepția cazului în care aveți deja 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 .