Programarea agile se bazează pe dezvoltarea produsului software în blocuri mici și incrementale, în care cerințele clienților și soluțiile oferite de programatori evoluează simultan.
Programarea agile are la bază o strânsă legătură între calitatea finală a produsului și livrările frecvente ale funcționalităților dezvoltate incremental. Cu cât se realizează mai multe livrări, cu atât calitatea produsului final va fi mai ridicată. Într-un proces de implementare agile , cerințele de modificare sunt privite ca un lucru pozitiv, indiferent de faza de dezvoltare în care se află proiectul. Aceasta deoarece cerințele de modificare evidențiază faptul că echipa a înțeles ceea ce este necesar pentru ca produsul software să satisfacă necesitățile pieței. Din acest motiv este necesar ca o echipă agil e să mențină structura codului cât mai flexibilă, astfel încât noile cerințe ale clienților să aibă un impact cât mai redus asupra arhitecturii existente. Aceasta nu înseamnă însă că echipa va face un efort suplimentar luând in considerare viitoarele cerințe și necesități ale clienților sau că va investi mai mult timp pentru a implementa o infrastructură care să suporte posibile cerințe necesare în viitor. Dar evidențiază faptul că accentul este pus de dezvoltarea produsului curent cât mai eficient. În acest acest scop, vom investiga câteva dintre principiile de software design care se impun aplicate de la o iterație la alta de către un programator agile , pentru a menține cât mai curat și flexibil posibil codul și designul proiectului. Principiile au fost propuse de către Robert Martin în lucrarea Agile Software Development: Principles, Patterns, and Practices [1].
O clasă trebuie să aibă un singur motiv pentru a fi modificată.
În contextul SRP, responsabilitatea poate fi definită ca "un motiv pentru modificare ". Atunci când cerințele proiectului se modifică, modificările vor fi vizibile prin modificarea responsabilității claselor. Dacă o clasă are mai multe responsabilități, atunci va avea mai multe motive de schimbare. Având mai multe responsabilități cuplate, modificări asupra unei responsabilități vor implica modificări asupra celorlalte responsabilități ale clasei. Acesta corelație conduce către un design fragil.Fragilitatea însemnă că o modificare adusă sistemului produce o ruptură în design, în locuri care nu au nici o legătură conceptuală cu partea care a fost modificată.
Exemplu:
Să presupunem că aveam o clasă care încapsulează conceptul de telefon și funcționalitățile aferente.
class Phone
{
public void Dial(const string&
phoneNumber);
public void Hangup();
public void Send(const string&
message);
public Receive(const string&
message);
};
Acesta clasă ar putea fi considerată rezonabilă. Toate cele patru metode definite reprezintă funcționalități referitoare la conceptul de telefon. Și totuși această clasă are două responsabilități. Metodele Dial si Hangup sunt responsabile pentru realizarea conexiunii, în timp ce metodele Send și Receive sunt responsabile pentru transmiterea datelor.
În cazul în care signatura metodelor responsabile pentru realizarea conexiunii ar fi supuse schimbărilor, acest design ar fi rigid, deoarece toate clasele care apelează metodele Dial și Hangup ar trebui recompilate. Pentru a evita această situație este necesar un re-design care să separe cele două responsabilități.
În acest exemplu cele două responsabilități sunt separate, astfel încât clasa care le utilizează - Phone, nu trebuie să le cupleze pe amândouă. Schimbările asupra conexiunii nu vor influența metodele responsabile cu transmisia datelor. Pe de altă parte în cazul în care cele două responsabilități nu prezintă motive de modificare în timp, nu este necesară nici separarea lor. Cu alte cuvinte responsabilitățile unei clase trebuie separate, numai dacă există șanse reale ca responsabilitățile să producă modificări, influențându-se reciproc.
Principiul singurei responsabilități este unul dintre cele mai simple și cu toate acestea unul dintre cele mai dificil de aplicat. Identificarea și separarea responsabilităților este unul dintre aspectele fundamentale ale designului softwar e. În principiile de agile software design pe care le vom analiza în continuare, vom vedea că vom reveni, într-un fel sau altul, asupra acestui principiu.
Entitățile software (clase, module, funcții, etc.) trebuie să fie deschise pentru extensii, dar închise pentru modificări.
Atunci când o singură modificare asupra unui modul software rezultă în necesitatea de a modifica o serie de alte module, atunci designul suferă de rigiditate . Principiul OCP susține refactorizarea designului astfel încât modificări ulterioare de același tip, nu vor mai produce modificări asupra codului existent , care deja funcționează, în schimb va necesita doar adăugarea de noi module.Un modul software care respectă principiul Deschis-Închis are două caracteristici principale:
Abstractizareaeste metoda care permite modificarea comportamentului unui modul software , fără a modifica și codul deja existent al acestuia. În C++, Java sau oricare alt limbaj orientat obiect, este posibil să se creeze o abstractizare care oferă o interfață fixă și un număr nelimitat de implementări, adică de comportamente diferite [2].
În Fig. 2 este prezentată o diagramă de clase care nu respectă principiul deschis-închis. Atât clasa Client cât și clasa Server sunt clase concrete. Clasa Client folosește clasa Server. Dacă mai târziu se dorește ca această clasă Client să folosească un alt tip de server, va fi nevoie să se modifice clasa Client astfel încât să utilizeze noul server. Figure 2 Exemplu care nu respecta principiul OCP 1
În Fig. 3 se prezintă același design ca și în Fig. 2 , dar de această dată principiul deschis-închis este respectat. În acest caz a fost introdusă clasa abstractă AbstractServer, iar clasa Client folosește această abstractizare. Totuși clasa Client va folosi de fapt clasa Server care implementează clasa ClientInterface. Dacă în viitor se dorește folosirea unui alt tip de server tot ce trebuie făcut va fi să se implementeze o nouă clasă derivată din clasa ClientInterface, dar de această dată clientul nu mai trebuie modificat.
Un aspect particular în acest exemplu, este modul în care am denumit clasa abstractă ClientInterface și nu ServerInterface , spre exemplu. Motivul pentru această alegere este faptul că clasele abstracte sunt mai apropiate de clasele client pe care le folosesc, decât de clasele concrete pe care le implementează.
Principiul Deschis -Închis este utilizat și în design pattern -urile Strategy și Plugin [3]. Spre exemplu, Fig.4 prezintă designul corespunzător care respectă principiul deschis-închis.
Clasa Sort_Object efectuează o operație de sortare a obiectelor, operație care poate fi descrisă în interfața abstractă Sort_Object_Interface . Clasele derivate din clasa abstractă Sort_Object_Interface sunt obligate să implementeze metoda Sort_Function(), dar au în același timp libertatea de a oferi orice implementare doresc pentru această interfață. Astfel comportamentul specificat de interfața metodei void Sort_Function() , poate fi extins și modificat prin crearea de noi subtipuri ale clasei abstracte Sort_Object_Interface.
În definiția clasei Sort_Object vom avea următoarele metode:
void Sort_Object::Sort_Function()
{
m_sort_algorithm->sortFunction();
}
void Sort_Object::Set_Sort_Algorithm(const Sort_Object_Interface* sort_algorithm)
{
std::cout << „Setting a new sorting algorithm...” << std::endl;
m_sort_algorithm = sort_algorithm;
}
Principalele mecanisme în spatele acestui principiu sunt abstractizarea și polimorfismul . Ori de câte ori codul trebuie modificat pentru implementarea unei noi funcționalități, trebuie să se ia în considerare și crearea unei abstracții care să furnizeze o interfață pentru comportamentul dorit și care să ofere în același timp posibilitatea de a adăuga în viitor noi comportamente pentru aceeași interfață. Desigur nu întotdeauna este necesară crearea unei abstractizări. Acestea metodă este utilă în general acolo unde apar modificări frecvente. Conformarea la principiul deschis-închis este costisitoare. Aceasta necesită timp de dezvoltare și efort pentru a crea abstracțiile necesare. Aceste abstracții incrementează complexitatea designului software .
În schimb, principiul deschis-inchis reprezintă din multe puncte de vedere un element central din programarea orientată obiect . Conformarea la acest principiu conduce la cele mai mari beneficii ale programării orientate obiect, acestea fiind flexibilitatea, reutilizarea și mentenanța codului.
Tipurile de bază trebuie să poată fi substituite de tipurile derivate.
În limbaje precum C++ sau Java, mecanismul principal prin care se realizează abstractizarea și polimorfismul este moștenirea. Pentru a crea o ierarhie de moștenire corectă trebuie să ne asigurăm că clasele derivate, extind fără a înlocui funcționalitatea claselor de bază. Cu alte cuvinte, funcțiile care utilizează pointer -i sau referințe la clasele de bază trebuie să poată folosi instanțe ale claselor derivate fară să își dea seama de acest lucru. În caz contrar, noile clase pot produce efecte nedorite când sunt utilizate în entitațile programului deja existent. Importanța principiului LSP devine evidentă în momentul în care este incălcat.
Exemplu:
Să presupunem că avem o clasă Shape, a cărei obiecte sunt deja folosite undeva în aplicație și care are o metoda SetSize , care proprietatea mSize ce poate fi folosită ca latură sau diametru, în funcție de figura reprezentată.
class Shape
{
public:
void SetSize(double size);
void GetSize(double& size);
private:
double mSize;
};
Mai târziu extindem aplicația și adăugăm clasele Square și Circle. Având în vedere faptul că moștenirea modelează o relație "este de tipul" ( IS_A relationship ) noile clase Square si Circle, pot fi derivate din clasa Shape.
Să presupunem în continuare că obiectele Shape sunt returnate de o metodă factory , pe baza unor condiții stabilite la run time , astfel încât nu cunoaștem exact tipul obiectului returnat. Dar știm că este Shape. Obținem obiectul Shape, îi setăm proprietatea size de 10 unități și îi calculăm aria. Pentru un obiect square aria va fi 100.
void f(Shape& shape, float& area)
{
shape.SetSize(10);
shape.GetArea(area);
assert(area == 100); // Oups!
// for circle area = 314.15927!
}
În acest exemplu, atunci când funcția f , primește ca parametru r , o instanță a clasei Circle va avea un comportament greșit. Deoarece, în funcția f, obiectele de tip Square nu pot substitui obiecte de tip Rectangle , principiul LSP este violat. Funcția f, este fragilă în raport cu ierarhia Square/Circle.Design by Contract
Desigur că mulți programatori, se vor simți inconfortabil cu noțiunea de "comportament" care trebuie să fie "corect". Cum am putea presupune ceea ce așteaptă utilizatorii/clienții claselor pe care le implementăm?În acest scop, ne vine în ajutor tehnica designului prin contract (design by contract - DBC). Contractul unei metode îl informează pe autorul unei clase client despre comportamentele pe care se poate baza cu siguranță. Contractul este specificat prin declararea precondițiilor și a postcondițiilor pentru fiecare metodă. Precondițiile trebuie să fie adevarate pentru ca metoda să se execute. Iar în final, după execuția metodei, aceasta garantează că postcondițiile sunt adevărate. Anumite limbaje, precum Eiffel oferă suport direct pentru precondiții și postcondiții. Acestea trebuie doar declarate, iar în timpul rulării sunt verificate automat. În C++ sau Java, această funcționalitate lipsește. Contractele pot fi în schimb specificate prin teste unitare (unit test ). Prin testarea comportamentului unei clase, testele unitare clarifică comportamentul unei clase. Programatorii care vor folosi clasele pentru care s-au implementat teste unitare, pot folosi aceste teste pentru a ști care este comportamentul pe care îl oferă claselor client.
Principiul LSP este doar o extensie a principiului Deschis-Închis și înseamnă că, atunci când adăugăm o nouă clasă derivată într-o ierarhie de moștenire, trebuie să ne asigurăm că clasa nou adăugată extinde comportamentul clasei de bază, fară a-l modifica.
A. Modulele de pe nivelurile ierarhice superioare nu trebuie să depindă de modulele de pe nivelurile ierarhice inferioare. Toate ar trebui să depindă doar de module abstracte.
B. Abstractizările nu trebuie să depindă de detalii. Detaliile trebuie să depindă de abstractizări.
Acest principiu enunță faptul că modulele de pe nivelul ierarhic superior trebuie să fie decuplate de cele de pe nivelurile ierarhice inferioare. Această decuplare se realizează prin introducerea unui nivel de abstractizare între clasele care formează nivelul ierarhic superior și cele care formează nivelurile ierarhice inferioare. În plus principiul spune și faptul că abstractizarea nu trebuie să depindă de detalii, ci detaliile trebuie să depindă de abstractizare. Acest principiu este foarte important pentru reutilizarea componentelor software. De asemenea, aplicarea corectă a acestui principiu face ca întreținerea codului să fie mult mai ușor de realizat.
În Fig. 6 este prezentată o diagramă de clase organizată pe trei niveluri. Astfel clasa PolicyLayer reprezintă nivelul ierarhic superior, ea accesează funcționalitatea din clasa MechanismLayer aflată pe un nivel ierarhic inferior. La rândul ei, clasa MechanismLayer accesează funcționalitatea din clasa UtilityLayer care de asemenea se află pe un nivel ierarhic inferior. În concluzie, este evident faptul că în diagrama de clase prezentată nivelurile superioare depind de nivelurile inferioare. Acest lucru înseamnă că dacă apare o modificare la unul din nivelurile inferioare există șanse destul de mari ca modificarea să se propage în sus spre nivelurile ierarhice superioare. Ceea ce înseamnă că nivelurile superioare mai abstracte depind de nivelurile inferioare care sunt mai concrete. Așadar se încalcă principiul inversării dependenței.
În Fig. 7 este prezentată aceeași diagramă de clase ca și în Fig. 6 , dar de această dată este respectat principiul inversării dependenței. Astfel, fiecărui nivel care accesează funcționalitatea dintr-un nivel ierarhic inferior i s-a adăugat o interfață care va fi implementată de nivelul ierarhic inferior. În acest fel interfața prin care cele două niveluri comunică este definită în nivelul ierarhic superior astfel încât dependența a fost inversată și anume nivelul ierarhic inferior depinde de nivelul ierarhic superior. Modificări făcute la nivelurile inferioare nu mai afectează nivelurile superioare ci invers. În concluzie, diagrama de clase din Fig. 7 respectă principiul inversării dependenței.
Programarea tradițională procedurală creează polițe de dependențe în care modulele ierarhice superioare depind de detaliile modulelor ierarhice inferioare . Acestă metodă de programare este ineficientă deoarece modificari ale detaliilor conduc către modificări și în modulele superioare.Programarea orientată obiect inversează acest mecanism de dependență, astfel încât atat detaliile cât și nivelurile superioare depind de abstractizări, iar serviciile aparțin deseori clienților.
Indiferent de limbajul de programare folosit, dacă dependențele sunt inversate atunci designul codului este orientat obiect. Dacă dependențele nu sunt inversate, atunci designul este procedural.Principiul dependenței inversate reprezintă mecanismul fundamental de nivel scăzut (low level ) ce stă la baza multor beneficii oferite de programarea orientată obiect. Respectarea acestui principiu este fundamentală pentru crearea de module reutilizabile. Este de asemenea esențială pentru a scrie cod rezistent la modificari. Atât timp cât abstracțiile și detaliile sunt izolate reciproc, codul este mult mai ușor de menținut.
Clienții nu trebuie să depindă de interfețe pe care nu le folosesc.
Acest principiu pune în evidență faptul că atunci când se definește o interfață trebuie avut grija ca doar acele metode care sunt specifice clientului să fie puse în interfață. Dacă într-o interfață sunt adăugate metode care nu au ce căuta acolo, atunci clasele care implementează interfața vor trebui să implementeze și acele metode. Spre exemplu, dacă se consideră interfața Angajat care are metoda Mănâncă , atunci toate clasele care implementează această interfața vor trebui să implementeze și metoda Mănâncă . Ce se întâmplă însă dacă Angajatul este un robot? Interfețele care conțin metode nespecifice se numesc interfețe poluate sau grase.În Fig. 8 este prezentată o diagramă de clase care conține: interfața TimerClient , interfața Door și clasa TimedDoor . Interfața TimerClient trebuie implementată de orice clasă care are nevoie să intercepteze evenimente generate de un Timer. Interfața Door trebuie să fie implementată de orice clasă care implementează o ușă. Având în vedere că a fost nevoie de modelarea unei uși care se închide automat după un anumit interval de timp în Fig. 3.7 este prezentată o soluție în care a fost introdusă clasa TimedDoor derivată din interfațaDoor , iar pentru a dispune și de funcționalitatea din TimerClient a fost modificată interfața Door astfel încât să moșteneascaă interfața TimerClient . Aceasta soluție însă poluează interfața Door deoarece toate clasele care vor moșteni această interfața vor trebui să implementeze și funcționalitatea din TimerClient [4].
class Timer
{
public:
void Register(int timeout, TimerClient* client);
} ;
class TimerClient
{
public:
virtual void TimeOut();
};
class Door
{
public:
virtual void Lock() = 0;
virtual void Unlock() = 0;
virtual bool IsDoorOpen() = 0;
} ;
Separarea interfețelor se poate realiza prin mecanismul moștenirii multiple. În Fig 9 se poate observa cum moștenirea multiplă poate fi folosită pentru a respecta în design principiul segregării interfețelor. În acest model, interfața TimeDoor , moștenește din ambele interfețe Door și TimerClient .
Clasele poluate sau ,,grase" conduc către cuplări greșite pentru clienții lor. Când un client efectuează o modificare asupra unei clase poluate, toți ceilalți clienți ai clasei poluate sunt afectați. De aceea, clienții trebuie să depindă numai de metodele pe care le apelează efectiv. Acest lucru poate fi realizat prin separarea interfețelor poluate, în mai multe interfețe specifice clienților. Fiecare interfața specifică unui client va conține numai metodele care sunt efectiv apelate de către client. Clasele ,,grase" pot apoi să moștenească toate interfețele specifice clienților și să le implementeze. Acest mecanism decuplează dependența clienților de metode pe care nu le apelează niciodată și permite clienților să fie independenți unii de alții.
Prin aplicarea repetată - de la o iterație la alta, a principiilor mai sus enunțate, se evită cele trei caracteristici care definesc o arhitectură software de slabă calitate: rigiditatea - sistemul este greu de modificat pentru că fiecare modificare afectează prea multe părți ale sistemului, fragilitatea - dacă se modifică ceva, apar tot felul de erori neașteptate șiimobilitatea - este dificil să se reutilizeze părți dintr-o aplicație pentru că nu pot fi separate de aplicația pentru care au fost dezvoltate inițial.
Designul agile reprezintă un proces care se bazează pe aplicarea continuă a principiilor agile și a design pattern -urilor astfel încât designul aplicației rămâne în mod constant simplu, curat și cât se poate de expresiv.
Pentru o aprofundare a principiilor enunțate, recomand cu încredere cartea lui Robert C. Martin, Agile Software Development - Principles, Patterns, and Practices.
[1] Robert Martin. - Agile Software Development: Principles, Patterns, and Practices. Editura Prentice Hall. 2012.
[2] Gamma, et al. - Design patterns. reading Ma: Addison-Wesley, 1998.
[3] Liskov, Barbara. - Data Abstraction and Hierarchy. SIGPLAN Notices, 23.5 (May 1988)
[4] Meyer, Bertrand. - Object oriented software construction, 2nd ed. upper Saddle River, Nj: Prentice Hall, 1997.