Cei 4 piloni ai programării orientate pe obiect
Poate te-ai fi gândit că te-ai prins deja cum stă treaba cu programarea orientată pe obiect. Avem clase cu care dăm naștere unor obiecte, iar obiectele le folosim pentru a executa niște cod. Ei bine, nu e doar atât. Puterea programării orientate pe obiect stă în concepte ceva mai abstracte de atât, dar o dată ce o să înțelegi utilitatea acestor concepte, vei înțelege și adevărata putere a acestei paradigme de programare. Concepte care au fost modelate în zeci de ani și care azi fac programarea orientată pe obiect să fie paradigma folosită în cele mai complexe sisteme software create vreodată.
Care sunt conceptele astea despre care vorbesc?
Află că există 4 concepte de bază folosite în toate limbajele de programare existente care aderă la paradigma orientării pe obiect.
Încapsularea
Încapsularea e tocmai organizarea codului în clase. De-aici cumva și denumirea de “încapsulare”, pentru a induce ideea unei capsule, care face ca interiorul să devină de neatins, steril. Prin organizarea codului în acest fel, protejăm datele de schimbări nedorite și oferim posibilitatea de a interacționa și manipula aceste date prin intermediul metodelor.
Poate te întrebi, ok, dar care-i avantajul?
Înainte de clase și obiecte, programatorii foloseau programarea procedurală. Care presupune organizarea codului doar în funcții care operau pe aceleași seturi de date. E probabil și unul din primele lucruri pe care le-ai învățat atunci când ai intrat prima dată în contact cu programarea. Să faci un set de funcții care executate secvențial, adică pe rând una căte una, să ducă la rezultatul dorit.
Deși pare interesant la început, o astfel de practică face foarte greoaie gestionarea stării datelor. Mai mult de atât, când era nevoie de o modificare în una din funcțiile respective, ea genera alte schimbări și alte schimbări, până când ajungeai să modifici extrem de mult din codul aplicației, doar pentru o schimbare minoră.
Uite exemplul de mai jos, orice apelant al clasei FileHandler
nu are niciun fel de informații legate de instrucțiunile pe care clasa le execută mai departe pentru a citi și scrie într-un fișier pe disc.
Apelanții, sau se mai numesc și “consumatori” (consumers), știu doar că pentru a scrie trebuie să transmită datele respective într-un parametru metodei Write()
, iar pentru a citi vor apela metoda Read()
. Cum anume se întâmplă asta la nivelul clasei sunt detalii de implementare și sunt în felul ăsta ascunse de apelanți, avantajul fiind că, intern, clasa poate suferi oricât de multe modificări fără să afecteze apelanții, atâta timp cât semnătura celor 2 metode nu se schimbă, desigur.
Abstractizarea
Abstractizarea e conceptul “ascundere” a unei implementări. Îți aduci aminte cănd spuneam că în programarea procedurală o schimbare minoră putea duce la refactorizarea unei cantități mult mai mari de cod? Pe lângă faptul că ne izolăm codul în clase pentru a reduce acest risc, abstractizarea vine și ea în ajutorul nostru, făcând interacțiunea dintre obiecte fără ca ele să știe prea multe unul despre celălalt. Abstractizarea unui obiect se face prin folosirea unei interfețe sau clase abstracte.
Ce fac acestea e să ofere un fel de manual de utilizare al unei clase anume, fără să ne dea detalii despre cum e construită clasa respectivă și nici despre cum funcționează intern. Gândește-te atunci când îți cumperi un televizor nou și primești un manual de utilizare în care ți se spune că dacă apeși pe butonul X, televizorul va schimba canalul, sau dacă apeși pe butonul Y, televizorul va da volumul mai tare.
Asta fără sa-ți dea detalii tehnice despre cum face asta.
Nu-ți spune că trimite un semnal din telecomandă care ajunge la televizor și care generează la rândul lui niște schimbări la nivelul electronicii. Toate detaliile astea sunt “abstractizate” de utilizatorul final, pentru că în cele din urmă nici măcar nu sunt de interes pentru el. Tot ce vrea e să poată schimba canalul și să dea volumul mai tare atunci când se uită la televizor.
Așa e și cu abstractizarea, dar în loc de telecomandă ai o interfață sau o clasă abstractă, pe care clasa o expune și un set de acțiuni pe care vrei să le execuți. Cum se întâmplă în cele din urmă acțiunile respective nu e de prea mare interes pentru tine ca și “consumator”.
Când vorbim de abstractizare, interfața sau clasa abstractă folosită poartă uneori și numele de “contract”. Pentru că așa cum toate părțile care sunt implicate într-un contract în viața reală, trebuie să-l și respecte, la fel se întâmplă și în cazul interfețelor sau claselor abstracte. Toate clasele care implementează o interfață sau extind o clasă abstractă trebuie cel puțin să declare și metodele impuse de acestea.
Îți aduci aminte când spuneam că abstractizarea ajută la reducerea propagării schimbărilor prin cod? Ei bine exact ăsta este scopul ei. Prin abstractizare spunem că facem codul să fie “decuplat” sau “decoupled”. Adică fiecare clasă folosește o interfață pentru a interacționa cu alte clase. Și astfel, nu legăm implementările directe ale claselor direct între ele, ci mai degrabă agreăm la un contract, care este reprezentat de interfețe sau clase abstracte (și implicit de metodele publice expuse de acestea). Astfel că atâta timp cât clasele respective respectă contractul, ele pot suferi modificări interne fără să afecteze celelalte clase implementate. Pentru că nu depind de ele direct, ci de ceea ce e expus în interfață. Astfel spunem că, codul nostru este “decuplat”.
În caz contrar, codul va fi “puternic cuplat” sau “tightly coupled”, ceea ce face ca o mică schimbare într-o anumită clasă, să propage schimbări prin multe alte și asta nu e ceea ce ne dorim.
Hai să-ți mai dau un exemplu, pentru că știu că e un concept mai dificil dacă îl auzi pentru prima dată.
Gândește-te la interfețe și clase abstracte ca la o priză de perete. Priza expune tipul de ștecher care poate fi introdus în ea și tu știi că orice ai băga acolo este alimentat la curent. Nu-ți pasă cum se întâmplă asta, cum face priza să-ți alimenteze ție electronicele, ci doar știi că o face. De asemenea, într-o priză poți băga un încărcător de telefon, o mașină de spălat, un frigider, un filtru de cafea etc. Pentru că toate astea respectă “contractul” sau “interfața” prizei, adică respectă tipul de ștecher care este folosit. Gândește-te ce s-ar fi întâmplat dacă fiecare producător de electrocasnice ar fi venit cu un tip de conexiune la priză diferit. Toată casa ta ar fi fost un haos, plină de adaptoare și de improvizații.
Moștenirea
Dacă termenii anteriori sunau puțin mai greu de digerat, moștenirea cred că pare puțin mai abordabil. Moștenirea e conceptul prin care o clasă poate moșteni o altă clasă, caz în care clasa care moștenește poartă denumirea de “subclass” sau sub-clasă și preia astfel toate proprietățile și metodele clasei pe care o moștenește. Moștenirea e un procedeu extrem de util atunci când vrei să împărtășești anumite funcționalități între mai multe clase, reducând astfel cantitatea de cod duplicat.
Sunt câteva aspecte mai delicate aici pentru a folosi moștenirea în mod corespunzător.
Pentru a nu crea relații greșite de extindere între tipuri, încearcă tot timpul să vezi dacă sub-clasa răspunde la întrebarea: IS A - Car IS A vehicle? Truck IS A vehicle? - Mașina este un vehicul? Camionul este un vehicul?
Dacă nu poate răspunde cu “da” la întrebarea asta e posibil ca relația pe care vrei s-o creezi să fie nepotrivită pentru moștenire. Dacă ai, de exemplu, o clasă denumită Engine
sau Gearbox
, cu siguranță niciuna din ele nu răspunde pozitiv la întrebarea IS A vehicle, și implicit nu poate moșteni clasa Vehicle
, fiind în schimb mai potrivită folosirea compoziției - folosirea unui tip ca ca parte a altui tip.
Polimorfismul
Cuvântul “polimorfism”, vine de la poli, care înseamnă mai multe, sau mai mulți și morf care vine de la morphḗ, care provine din greaca antică și înseamnă formă. Traducerea pentru polimorfism devine astfel “multiformă”.
Polimorfismul e cel care oferă cireașa de pe tort în tot acest cocktail denumit programare orientată pe obiect. E conceptul prin care obiectele se pot comporta în mod diferit, fără ca tipul lor declarat să se schimbe.
Cum se poate întâmpla una ca asta? Ei bine, e folosită moștenirea (sau abstracatizarea, va funcționa la fel) despre care tocmai am vorbit mai devreme. Avem un set de clase care extind o clasă de bază, fiecare având propria implementare a metodelor moștenite din clasa de bază. Declarăm acum variabilele folosind tipul clasei abstracte.
Asta satisface bineînțeles compilatorul, pentru că din punctul lui de vedere fiecare instanță a unei subclase, poate înlocui o instanță a clasei de bază. Acum, apelând metoda MakeSound()
pe fiecare obiect, rezultatul va fi diferit, pentru că tipul obiectului din spatele fiecărei variabile e diferit. Astfel, se spune că obiectele se comportă polimorfic.
abstract class Animal
{
abstract void MakeSound();
}
class Dog : Animal
{
override void MakeSound()
{
Console.WriteLine("Bark");
}
}
class Cat : Animal
{
override void MakeSound()
{
Console.WriteLine("Meow");
}
}
Animal cat = new Cat();
Animal dog = new Dog();
cat.MakeSound(); // Meow
dog.MakeSound(); // Bark
Concluzie
Știu că astfel de concepte sunt ceva mai abstracte, mai ales dacă nu ai scris încă suficient cod pentru a înțelege ce probleme rezolvă, dar sunt extrem de necesare de aprofundat pentru că sunt parte din cele mai frecvente întrebări pe care o să le primești la interviu.
Deși utile în interviurile tehnice, aceste concepte sunt totuși și extrem de necesare pentru a înțelege codul aplicațiilor mai complexe. Ele nu sunt doar noțiuni teoretice folosite în interviuri, precum o mare parte din problemele de algoritmică, de exemplu. Cei 4 piloni ai programării orientate pe obiect au aplicabilitate și sens chiar și în practică.
Dacă totuși nu a făcut “click” încă, nu trebuie să-ți faci griji, tot ce ai nevoie e puțină practică.
Atât pentru azi, ne auzim data viitoare.