Iniţiere în programarea orientată pe obiecte din perspectivă C++ şi Java
Dorin Bocu
...O parte din eforturile ştiinţei calculatoarelor sunt dedicate îmbunătăţirii permanente a paradigmelor de modelare. Prin istoric şi prin realizările de ultimă oră, ingineria softului nu face decât să confirme această aserţiune. Modelarea orientată pe obiecte este un exemplu remarcabil de instrument, gândit pentru a fi utilizat în realizarea de sisteme soft, competitive din punct de vedere al preţului şi al calităţii. Programarea orientată pe obiecte îngăduie fanilor ei să verifice, în practică, forţa unui stil de a modela, a cărui capacitate de a mapa domeniul problemei peste domeniul soluţiei este cu mult superioară altor paradigme.
Cuvânt înainte al autorului Au trecut ani buni de când lumea a început să întrebuinţeze, în vorbire şi în alte contexte, sintagma “programare obiect orientată” sau, ceva mai aproape de spiritul limbii române, “programare orientată pe obiecte”. Pregătită şi anunţată de numeroasele controverse pe marginea preamultelor slăbiciuni ale vechilor paradigme de programare, orientarea pe obiecte s-a instalat confortabil de-a lungul întregului proces de dezvoltare a unui sistem soft, devenind în zilele noastre “religia care guvernează atât etapa de modelare a unui sistem soft cât şi etapa de implementare”. Au apărut limbaje, precum şi medii de programare şi dezvoltare a sistemelor soft, a căror arhitectură este esenţial tributară ideii de orientare pe obiecte (Java, C++, C#, Object Pascal – câteva exemple mai cunoscute de limbaje, Delphi, C-Builder, Visual C++ câteva exemple mai cunoscute de medii de programare, Rational Rose, ObjectiF – două dintre mediile de dezvoltare cu bună răspândire în lumea ingineriei softului). Este clar, orientarea pe obiecte nu este doar o modă în programare, ci o modalitate, fără rival, pentru moment, de a dezvolta sisteme soft. Pentru informaticienii a căror practică se reduce la “apropierea cât mai rapidă de tastatură” pentru a rezolva o problemă, orientarea pe obiecte este o încercare care tulbură minţile şi întârzie rezolvarea problemei. Seriile de studenţi care “mi-au trecut prin mână” mi-au întărit convingerea că însuşirea spiritului orientării pe obiecte este o problemă destul de dificilă, deoarece, aproape tot ceea ce este reclamat de obşnuinţele omului în materie de învăţare, este dificil de operaţionalizat când este vorba de însuşirea acestui spirit. Mult mai apăsat decât în alte paradigme, în orientarea pe obiecte, specialistul trebuie să acorde atenţia cuvenită elaborării soluţiei înainte de a o implementa. Metodele cele mai răspândite de învăţare a limbajelor de programare se bazează pe formula: Prin aplicaţii, spre descoperirea subtilităţilor sintactice, semantice şi pragmatice ale unui limbaj de programare. Cititorul atent a înţeles faptul că învăţarea unui limbaj de programare este un exerciţiu de voinţă care presupune parcurgerea a trei etaje: Învăţarea stereotipurilor sintactice fundamentale ale limbajului (sintaxa limbajului) Descoperirea unui număr cât mai mare de semantici primitive, care pot fi învelite cu sintaxa limbajului (semantica limbajului)
Formarea unor deprinderi de utilizare eficientă a limbajului, în funcţie de natura şi complexitatea problemei, pentru rezolvarea respectivei probleme (pragmatica limbajului). Ideea călăuzitoare a acestei cărţi este de a invita cititorul să înveţe singur sintaxa orientării pe obiecte, încercând să îl ajute, îndeosebi în efortul de deconspirare a semanticii şi pragmaticii orientării pe obiecte. Trimiterile de natură sintactică vor fi raportate la limbajele Java şi C++, suficient de înrudite din punct de vedere sintactic pentru a nu pune la grea încercare disponibilitatea la effort de rutină a cititorului. Ceva mai rar, se vor face trimiteri şi la Object Pascal, un limbaj cu o istorie asemănătoare limbajului C++, din punct de vedere al modului în care permite îmbinarea trecutului şi a prezentului, în materie de suport pentru paradigmele de modelare. Trebuie să mărturisesc, totodată, faptul că, în viziunea acestui cărţi, fiecare cititor este o instanţă cognitivă activă, capabilă de effort permanent de abstractizare, singura modalitate de a ajunge la o înţelegere superioară a esenţei unei paradigme de modelare şi, în particular, de programare. Atitudinea de spectator, cu instinct de conservare puternic, la derularea ideilor din această carte, este absolut contraproductivă pentru atingerea obiectivului fundamental: învăţarea cât mai multor elemente - suport, esenţiale pentru modelarea / programarea orientată pe obiecte a soluţiei unei probleme. Ce se va întâmpla cu cei care nu se vor putea împăca cu ideea de a se dezmorţi voluntar, iată o problemă în legătură cu care prefer să spun doar atât: nu peste mult timp vor descoperi că au îmbătrânit înainte de vreme, fără a fi înţeles mare lucru despre plăcerea de a uita de scurgerea timpului, realizând lucruri care să uimească, deopotrivă, pe alţii şi pe propriul lor creator. Referindu-mă la studenţii pentru care, în principal, am scris această carte, trebuie să spun că, în viziunea mea, ideea de student se confundă cu imaginea unui individ care are apetenţă pentru studiu. Dacă, în facultate, se mai studiază şi discipline care, unora li se par, nefolositioare, datoria studentului este să facă efortul de a se identifica cu ideile fundamentale ale unui număr cât mai mare de discipline, socotite de el folositoare. Dacă, în facultate, studenţii se mai întâlnesac şi cu profesori ale căror căutări nu sunt încă suficient de bine sistematizate, acesta nu este un motiv de a renunţa la dorinţa de a cunoaşte.
De la natură, studentul poate fi asimilat cu un obiect care are toate capabilităţile necesare pentru a parcurge drumul dificil, dar pasionant, al cunoaşterii elementelor fundamentale, în diferite ramuri ale matematicii şi ştiinţei calculatoarelor. Aş aminti, tuturor celor care nu au realizat încă, faptul că omul are nevoie de ştiinţă pentru a înţelege o parte din realitatea necunoscută ( rezultă că ştiinţa are funcţie explicativă), pentru a modela comportamentul unor fenomene şi procese din realitatea obiectivă, în vederea optimizării dinamicii lor (rezultă că ştiinţa are funcţie modelatoare), pentru a îmbogăţi realitatea obiectivă cu obiecte artificiale (rezultă că ştiinţa are funcţie demiurgică). Sper ca cititorul să înţeleagă bine rolul activ pe care trebuie să şi-l asume în deconspirarea, prin studiu individual şi exerciţii perseverente, a etajelor sintactice esenţiale programării în C++ şi Java. * Asemenea celorlaltor cărţi scrise de mine şi aceasta, prin bunăvoinţa specialiştilor de la Editura Albastră, porneşte către cititor, în condiţii grafice deosebite, parcă pentru a îndrepta o parte din neîmplinirile de care încă ne mai facem vinovaţi în faţa judecăţii necruţătoate a timpului.
Capitolul 1 Cum se explică permanenta nevoie de paradigme noi în ingineria softului Ce înţelegem prin orientarea pe obiecte
1.1 Cum se explică permanenta nevoie de paradigme noi în ingineria softului? Binefacere sau blestem, omenirea este, asemenea întregului univers, într-o continuă deplasare spre alte repere ontologice şi gnoseologice. Avea dreptate Heraclit din Efes când, folosind cuvinte meşteşugit alese, constata că singura certitudine a universului pare a fi devenirea. Dacă, acum peste 2500 de ani în urmă, devenirea ocupa o poziţie centrală în modul de gândire al lui Heraclit, în zilele noastre devenirea s-a transformat în izvor nesecat şi cauză a tuturor transformărilor importante pe care le suportă universul cunoscut omului. S-ar părea că nimic din ceea ce face omul nu durează. Acest fapt este, după ascuţimea simţurilor noastre, în opoziţie cu ceea ce Natura sau Marele Creator fac. Omul aspiră la eternitate luptând cu efemerul inerent al creaţiilor lui. Marele Creator este eternitatea însăşi. Las pe seama filozofilor continuarea efortului de preamărire sau punere la index a rolului pe care îl joacă devenirea în viaţa omului şi, de ce nu, în univers. Caracterul obiectiv al devenirii poate explica, în genere şi nevoia de paradigme1 noi în ingineria softului şi, în particular în programare. * Permiţându-mi o scurtă digresiune, ingineria softului este o ramură a ştiinţei calculatoarelor care se ocupă, la urma urmei, de studierea unei probleme extrem de delicate: “Dată o problemă oarecare, ce trebuie făcut pentru a o rezolva cu ajutorul calculatorului?” Confruntaţi de-a lungul timpului, cu diferite tipuri de probleme, specialiştii în ingineria softului au făcut o descoperire banală: Efortul depus pentru a rezolva o problemă cu ajutorul calculatorului este direct proporţional cu complexitatea problemei. Odată făcută această descoperire iese la iveală altă întrebare: “Cum definim complexitatea unei probleme?” Atenţie, cititorule, este vorba de complexitatea unei probleme nu de complexitatea soluţiei algoritmice a unei probleme. Complexitatea unei probleme este o caracteristică intrinsecă a enunţului asumat al acesteia.
Este cazul să spunem că, prin paradigmă se înţelege, fără a exagera cu explicaţiile, un mod de abordare a problemelor dintr-un anumit domeniu, evident, cu scopul de a rezolva aceste probleme. Poate că limba română nu avea neapărată nevoie de acest cuvânt, dar insistenţa cu care este utilizat în alte părţi poate fi considerată o scuză pentru utilizarea lui în această carte. 1
Complexitatea soluţiei algoritmice a unei probleme este o caracteristică intrinsecă a modului în care o anumită instanţă cognitivă (de pildă un student din anul II) obţine respectiva soluiţie algoritmică. Este, sper, cât se poate de clar faptul că cele două tipuri de complexitate cuantifică caracteristicile structurale ale unor colecţii de obiecte, diferite din punct de vedere al sferei de cuprindere şi al conţinutului. Cei care au parcurs unul sau mai multe cursuri de iniţiere în Algoritmică şi Programare îşi amintesc, probabil, înţelesul pe care îl are complexitatea unui algoritm. Formalizarea matematică a complexităţii algoritmilor este un prilej minunat de a ilustra forţa de analiză a matematicii de puterea continuului, aplicată universului algoritmilor, al căror comportament este eminamente discret. Este corect să evidenţiem, în acest punct, utilitatea teoretică şi practică (îndeosebi în cazul aplicaţiilor critice relativ la complexitatea algoritmilor folosiţi) a eforturilor de clasificare a algoritmilor secvenţiali şi paraleli, în funcţie de complexitatea lor Acest gen de afirmaţii sunt prezentate în toată cuprinderea şi adâncimea lor în cărţi fundamentale pentru învăţărea programării calculatoarelor2. Ingineria softului (IS) ia act de această realitate şi, în replică, îşi concentrează atenţia asupra complexităţii problemelor. O încercare de a evidenţia elementele cu ajutorul cărora putem descrie complexitatea unei probleme în ingineria softului ne conduce la seria de observaţii de mai jos. Rezolvarea unei probleme cu ajutorul calculatorului înseamnă, de cele mai multe ori, simularea / asistarea de către calculator a unor activităţi, desfăşurate de către sisteme de altă natură. Aşadar, aproape invariabil, ne aflăm în faţa unor dificultăţi care rezultă din caracterul instabil al relaţiei dintre model şi sistemul modelat. Neglijarea acestui aspect simplifică efortul de realizare, în cele din urmă, a unui sistem soft, dar preţul plătit este exprimat, sintetic, astfel: uzură morală rapidă şi posibilităţi de adaptare reduse. Se poate lupta cu mijloace raţionale împotriva caracterului instabil al relaţiei dintre model şi sistemul modelat? Răspunsul este: da, se poate lupta, dacă specialistul în IS este dispus să anticipeze impactul posibilelor schimbări ale sistemului modelat asupra soluţiei (modelului). Anticipând schimbările sistemului modelat, simplificăm efortul de adaptare a soluţiei la aceste schimbări. Modelul care stă la baza soluţiei unei probleme abstractizează viziunea specialistului sau grupului de specialişti în IS, asupra 2
Knuth, Cormen
sistemului modelat. Semn al posibilităţilor noastre limitate de a cunoaşte, orice viziune nu poate decât să aproximeze comportamentul sistemului modelat. Chiar şi în aceste condiţii, de la a aproxima defectuos până la a aproxima inspirat este nu doar un drum lung ci şi plin de diverse tipuri de încercări. Exemple de astfel de încercări: specificarea completă a cerinţelor funcţionale; acordarea atenţiei cuvenite cerinţelor non-funcţionale; alegerea inspirată a modalităţilor de armonizare a cerinţelor contradictorii; alegerea inspirată a paradigmei de modelare, etc. Trecerea, cu succes, peste dificultăţile schiţate mai sus este realizată “în condiţii ergonomice” dacă managementul acestor dificultăţi (plus nenumărate altele) este tratat cu maximum de seriozitate şi competenţă. Încercând să forţăm o concluzie, complexitatea unei probleme în IS este influenţată de: 1.
Stabilitatea relaţiei model-sistem modelat; lipsa de stabilitate în această relaţie, asumată conştient, induce creşterea complexităţii problemei de rezolvat.
2.
Complexitatea structurii sistemului modelat (creşterea acesteia induce, de asemenea, creşterea complexităţii problemei de rezolvat).
3.
Desfăşurarea în condiţii de eficienţă a procesului de rezolvare a unei probleme, adaugă un plus de complexitate oricărei probleme. Neasumându-se acest plus de complexitate, putem uşor compromite procesul de realizare a unui sistem soft (=soluţia executabilă pe un calculator real a unei probleme date). Un management bun are de rezolvat probleme de procurare şi alocare optimă a resurselor, comunicare între partenerii de proiect (=specialiştii în IS, beneficiarii, utilizatorii), lansare cu succes pe piaţă (dacă este cazul), etc. Nu aş sfătui pe nici un specialist în IS să considere normală o afirmaţie de genul: “Dacă cele spuse la punctul 2 sunt tratate cu maximum de seriozitate şi competenţă, 1 şi 3 nu mai înseamnă mare lucru”. Îndeosebi în cazul proiectelor mari, s-a dovedit, de nenumărate ori, neadevărul unei astfel de afirmaţii.
În IS, complexitatea problemelor nu este un accident; mai mult, putem spune că este legitim să ne aşteptăm la elemente de complexitate chiar şi acolo unde s-ar părea că acestea sunt lipsă. Dacă mai amintim cititorului observaţia tendenţioasă, potrivit căreia complexitatea de calitate se întemeiază pe simplitate, acesta înţelege mai bine insistenţa cu care prezint implicaţiile complexităţii unei probleme asupra calităţii soluţiei acestei probleme. Calitate? Complexitate3? Complexitate de calitate? Multe se mai pot spune. Specialistul în IS doreşte să ştie câteva lucruri simple: Care sunt primejdiile? Care sunt exigenţele? Cum se poate evita un eşec?, etc. Astfel de întrebări, ne vom pune, într-o formă sau alta şi în această carte de iniţiere în programarea orientată pe obiecte şi le puteţi găsi reluate în cărţile Iniţiere în ingineria sistemelor soft şi Iniţiere în modelarea obiect orientată a sistemelor soft utilizând UML, scrise de D. Bocu şi apărute la Editura Albastră. Nevoia presantă de a căuta, permanent, răspunsuri noi la astfel de întrebări, justifică avalanşa de noutăţi care caracterizează şi lumea limbajelor de modelare, specifice ingineriei softului.
1.2 Ce se înţelege prin orientarea pe obiecte? S-au scris destule cărţi pe tema orientării spre obiecte. Şi mai multe sunt, probabil, articolele din revistele de specialitate. Unele, foarte convingătoare. Altele mai puţin. Nu cred că voi umple, brusc, golul care mai există, încă, în limba română, scriind această carte. Dar voi încerca să fac înţeles modul de gândire al unui specialist în IS, care vede realitatea informaţională în termeni specifici orientării spre obiecte. Orice sistem informaţional4 este mai uşor de înţeles dacă avem elemente suficiente referitoare la datele vehiculate în sistem, procedeele de prelucrare (vehiculare) a datelor din sistem, interfeţele sistemului cu mediul în care acesta operează. Fiecare dintre elementele specificate mai sus are reguli proprii de organizare şi fiinţare. În plus, mai există şi relaţiile strânse dintre aceste elemente. De fapt, întreaga istorie a IS se învârte în jurul ecuaţiei prezentată în Figura 1. Orice exerciţiu de modelare urmăreşte, indiferent de acuitatea mijloacelor de investigaţie, un anumit mod de organizare a complexităţii sistemului modelat. 4 Ca şi realitatea informaţională, sistemul informaţional desemnează un ansamblu de resurse care optimizează fluxurile informaţionale dintr-un sistem gazdă. Pentru o documentare mai atentă invit cititorul să răsfoiască atent o carte bună de ingineria softului. 3
<Soluţia unei probleme>
= <Organizarea datelor> + <Organizarea prelucrărilor> + <Optimizarea interfeţelor>
Figura 1. Ecuaţia generală a soluţiei unei probleme în IS. Ecuaţia de mai sus a primit de-a lungul timpului numeroase rezolvări. * Înainte de a descrie tipurile fundamentale de rezolvări, voi efectua o scurtă trecere în revistă a vocabularului IS, esenţial în comunicarea dintre partenerii unui proiect de dezvoltare a unui sistem soft. Prin urmare, o firmă producătoare de soft produce şi livrează produse şi servicii care se adresează nevoilor şi cerinţelor clienţilor. Cerinţele clienţilor constitue un exemplu de problemă cu care trebuie să se confrunte echipa de dezvoltare. Produsele şi serviciile care satisfac aceste cerinţe pot fi considerate ca fiind soluţii. Pentru a livra soluţii valide (= de calitate, la preţ de cost redus şi în timp util) firmele trebuie să facă permanent achiziţie, comunicare (partajare) şi utilizare de cunoştinţe specifice referitoare la domeniul problemei de rezolvat. Pentru a discrimina şi comunica eficient informaţiile necesare pentru rezolvarea unei probleme, firmele folosesc tehnologii adecvate. O tehnologie este, în general vorbind, un instrument pe care membrii echipei de dezvoltare trebuie să înveţe s-o folosească la întregul ei potenţial. Aşadar, problema de rezolvat, la care se adaugă necesitatea de a învăţa modul de utilizare a tehnologiilor necesare în rezolvarea problemei, ne permit o imagine, încă optimistă, asupra complexităţii efortului de realizare a unui sistem soft. Amplificarea acestei complexităţi este motivul pentru care întregul efort de realizare a unui sistem soft este structurat sub forma unui proiect. În cadrul unui proiect se desfăşoară, după reguli precise, toate activităţile necesare pentru a asigura succesul efortului de realizare a unui sistem soft. Două dintre dimensiunile esenţiale pentru succesul unui proiect sunt limbajul de modelare folosit şi tipul de proces utilizat pentru a materializa forţa limbajului de modelare. Despre toate acestea, mai multe aspecte în [Iniţiere în ingineria sistemelor soft]. * Revenind la întrebarea de bază a acestui paragraf, să observăm că, încă de la apariţia primelor calculatoare, a apărut o nouă dilemă în faţa omenirii:
Cum putem învăţa calculatoarele să ne rezolve în mod optim problemele? La început, când calculatoarele erau folosite exclusiv pentru rezolvarea unor probleme cu carater ştiinţific, soluţia era programarea în cod maşină. Dificultăţile de bază în rezolvare acestor tipuri de probleme erau două :elaborarea modelelor matematice şi transpunerea lor în cod maşină. Mai ales programarea în cod maşină, era un gen de exerciţiu la care nu se îngrămădeau decât indivizi care manifestau un interes ieşit din comun pentru sistemele electronice de calcul din acea generaţie. Apariţia limbajelor de asamblare a generat o relaxare a “cortinei de fier” instalată de codul maşină între calculatoare şi marea masă a curioşilor. O relaxare asemănătoare s-a produs şi în ceea ce priveşte tipurile de probleme abordabile cu ajutorul calculatoarelor. Era momentul în care intrau pe scenă aplicaţiile de gestiune, datorită apariţiei memoriilor externe. Locul datelor simple începe să fie luat de date structurate şi din ce în ce mai voluminoase. Presiunea exercitată de cererea crescândă de aplicaţii de gestiune a impus trecerea la limbajele de nivel înalt şi mediu. În paralel, cu deosebire în faţa proiectelor uriaşe (controlul traficului aerian, gestiunea tranzacţiilor unor bănci, informatizarea tot mai multor activităţi ale intreprinderilor industriale) apare nevoia disciplinării procesului de derulare a acestor proiecte din faza de specificare a cerinţelor, trecând prin faza de analiză, continuând cu proiectarea soluţiei şi terminând (grosier vorbind) cu programarea. De unde această nevoie de disciplinare? Foarte simplu, complexitatea problemelor (parcă am mai auzit undeva despre acest lucru) nu mai putea fi stăpânită lucrând fără metodă. Astfel au apărut, de-a lungul timpului, tot felul de mode în ceea ce priveşte abstractizarea soluţiei şi nu numai. Managementul unui proiect trebuie să aibă, permanent, în vizor, cel puţin, asigurarea limbajului de modelare adecvat şi a modelului de dezvoltare optim5. De la moda programelor-mamut (singura posibilă în vremea programării în cod maşină) s-a ajuns treptat la nevoia modularizării (= descompunerea problemei iniţiale în subprobleme, eventual aplicarea repetată a acestui procedeu, obţinându-se nişte piese ale soluţiei numite module, care, asamblate, compuneau, în sfârşit soluţia). Întrebarea care a „furnicat”, ani la rând, creierele teoreticienilor şi practicienilor, deopotrivă, era: Care sunt criteriile după care se face modularizarea unei soluţii?
Mai multe detalii în această privinţă în cartea Iniţiere în ingineria sistemelor soft, D. Bocu, Editura Albastră, Cluj-Napoca, 2002 5
Există o serie de factori externi ai calităţii unui sistem soft care ţin sub presiune tendinţa de a modulariza de amorul artei, precum: corectitudinea, robusteţea, reutilizabilitatea, portabilitatea, etc. Există şi o serie de factori interni ai calităţii unui sistem soft, care presează, în egală măsură, precum: structurarea soluţiei, claritatea algoritmilor, calitatea arhitecturii, etc. Menţinerea în echilibru a acestor factori de presiune este o sarcină extrem de dificilă. În cele din urmă, cercetătorii au ajuns la concluzia că, din punct de vedere al celui care lucrează, materializarea acestor factori la parametri satisfăcători sau marginalizarea lor, depind de decizia luată în ceea ce priveşte relaţia dintre date şi prelucrări în procesul de modularizare a soluţiei unei probleme. Istoria ne arată că au existat teoreticieni şi practicieni încredinţati că singura metodă de modularizare validă este modularizarea dirijată de date (altfel spus, înainte de a te ocupa de organizarea prelucrărilor, rezolvă, în mod optim, problema organizării datelor; din schema de organizare a acestora se va deriva şi schema de structurare a prelucrărilor). Tot istoria ne arată că au existat şi teoreticieni şi practicieni încredinţati că singura metodă de modularizare validă este modularizarea dirijată de prelucrări (altfel spus, ocupă-te, mai întâi, de organizarea prelucrărilor şi, mai apoi, fă rost de datele necesare şi organizează-le conform cerinţelor prelucrărilor). Curând s-a observat că dihotomia date-prelucrări nu este benefică sub nici o formă. A existat o soluţie de compromis (modularizarea dirijată de fluxurile de date), care s-a dovedit în timp nesatisfăcătoare în numeroase situaţii din realitate. Din punct de vedere al criticilor fundamentale care se pot formula la adresa acestor abordări esenţa este următoarea: Dacă soluţia unei probleme înseamnă ansamblul date-prelucrări, fiecare componentă având o existenţă relativ autonomă şi reguli proprii de organizare, atunci avem situaţia din Figura 2.
DATE Problemă (activitate-sistem real al cărui comportament trebuie modelat)
PRELUCRĂRI
Figura 2. Perspectiva clasică asupra relaţiei dintre date şi prelucrări în structura unei soluţii
Esenţial în mesajul transmis de Figura 2 este faptul că datele sunt sficient de izolate de prelucrări pentru ca o modificare în structura unei componente să dea uşor peste cap structura celeilalte componente. Conform paradigmelor caracterizate în Figura 2, era posibilă o caracterizare de tipul celei prezentate pentru problema de mai jos. Problema 1: Să se realizeze un sistem soft care simulează deplasarea unui om pe o suprafaţă plană. Dacă aş fi un partizan al modularizării dirijate de date, mai întâi mi-aş pune următoarele întrebări: care sunt atributele informaţionale care caracterizează un om (stilizat convenabil, să spunem)?. Cum se caracterizează un plan? Cum se memorează intern şi extern datele despre aceste obiecte? După ce am “distilat” satisfăcător lumea datelor, încep să mă ocup şi de lumea prelucrărilor necesare în jurul acestor date. Obţin, inclusiv în viziunea limbajelor de programare suport, două lumi, care interferă, dar au reguli de organizare şi reprezentare în memorie distincte. Mai trist, legătura dintre date şi prelucrări este o problemă care se gestionează prin programare, ca şi când nu ar fi suficiente problemele celelalte. Ce ne facem, însă, dacă apare necesitatea simulării deplasării unei vieţuitoare în genere, pe o suprafaţă plană? Pentru fiecare vieţuitoare în parte iau travaliul de la capăt? Posibil, dar total contraindicat din multe puncte de vedere( preţ de cost, extensibilitate, încadrare în termenele de execuţie, etc.). Este limpede, reformulat ca mai sus, enunţul problemei ne pune în faţa sarcinii de a modela comportamentul unor obiecte, heterogene ca tip, dar între care există afinităţi, atât de natură informaţională cât şi comportamentală. Astfel apare, ceea ce în modelarea obiect orientată se numeşte problema gestiunii similarităţilor unor colecţii de obiecte. Gestiunea corectă a similarităţilor unei colecţii de obiecte, heterogene din punct de vedere al tipului definitor, se realizează desfăşurând în paralel efort de clasificare şi, acolo unde este cazul, ierarhizare cu ajutorul operaţiilor de generalizare / specializare. Cum arată lumea privită din această perspectivă? Simplificând intenţionat, cam ca în Figura 3. Nu voi da, încă, nici o definiţie, dar voi face o serie de observaţii pe care le voi aprofunda în capitolele următoare. 1.
Se insinuează faptul că soluţia orientată pe obiecte a unei probleme se obţine în urma unui demers de organizare a unor obiecte, care au atât proprietăţi informaţionale cât şi comportament (se manifestă, astfel, într-un cadru absolut natural, un mai vechi principiu utilizat în modelare şi anume principiul încapsulării datelor şi prelucrărilor. Încapsulare facem şi în Pascal, când scriem unit-uri care furnizează
anumite servicii unor categorii bine definite de utilizatori. Aceste unit-uri au interfaţă şi implementare. Soluţia orientată pe obiecte a problemei
C1
Problemă (activitate-sistem real al cărui comportament trebuie modelat)
C11
C12
C111 C121
C122
Figura 3. Perspectiva orientată pe obiecte a soluţiei unei probleme Manifestarea principiului încapsulării, în acest cadru, însemna ascunderea detaliilor de implementare faţă de utilizatori, prin publicarea unei interfeţe stabile. O interfaţă este stabilă dacă utilizatorul ei nu sesizează eventualele modificări aduse implementării interfeţei. 2.
În sfârşit, aplicând riguros principiul încapsulării, putem defini clase de obiecte care au o importanţă esenţială pentru maparea domeniului problemei peste domeniul soluţiei. Există, însă, numeroase alte raţiuni pentru care principiul încapsulării se aplică conjugat cu alt principiu important în modelarea orientată pe obiecte: principiul moştenirii. Aplicarea acestui principiu ne ajută să raţionalizăm redundanţele care apar, în mod inerent, în procesul de elaborare a unei soluţii în genere. Mai mult, principiul moştenirii pregăteşte terenul pentru rezolvarea unor probleme interesante care ţin de polimorfism şi genericitate. Exemplul de referinţă în această privinţă este Java.
Fără a mai insista prea mult, să desprindem concluzia care se impune evident la acest nivel de prezentare: Modelând orientat pe obiecte, asigurăm maximum de corespondenţă posibilă între obiectele care populează
sistemul modelat şi obiectele care dau, practic, viaţă soluţiei. Lucru absolut remarcabil, deoarece principiul încapsulării (temelie a modularizării de calitate în orientarea pe obiecte) introduce elemente de stabilitate deosebită a soluţiei chiar şi în situaţia în care apara modificări în sfera domeniului problemei. Prăpastia dintre date şi prelucrări este înlocuită de reguli precise de asociere a datelor şi prelucrărilor, pentru a descrie tipuri de obiecte întâlnite în domeniul problemei şi care sunt importante pentru economia de resurse a soluţiei. Din această perspectivă privind lucrurile, este evident faptul că modelarea orientată pe obiecte este altceva decât modelarea clasică (indiferent de nuanţă). Desluşirea acestui altceva, la nivel de sintaxă, semantică şi pragmatică (prin raportare la un limbaj de programare) ne va preocupa în continuare. Cei care se grăbesc să abordeze şi specificul modelării orientate pe obiecte, abstracţie făcând de limbajele de programare- suport pentru implementare, pot consulta lucrarea [Iniţiere în modelarea obiect orientată utilizând UML6].
6
Dorin Bocu, Editura Albastră, Cluj-Napoca, 2002
Capitolul 2 Concepte în programarea orientată pe obiecte Principii în programarea orientată pe obiecte
2.1 Concepte în programarea orientată pe obiecte Începând cu acest capitol, orientarea pe obiecte va fi privită, nu de la înălţimea preceptelor ingineriei softului, ci din perspectiva programării Java şi C++, îndeosebi. Subliniez, încă odată, marea provocare pentru un programator care încearcă forţa unui limbaj în materie de obiect orientare nu este în sintaxă, semantica asociată diferitelor tipuri de enunţuri sintactice sau stilul de codificare, ci însuşirea spiritului orientării pe obiecte aşa cum este el promovat de elementele suport ale limbajului. De aceea, reamintesc cititorului conştient faptul că va trebui să se întrebuinţeze serios pentru a descifra, dacă mai este cazul, oferta limbajului C++ în ceea ce priveşte: tipurile fundamentale de date (similarităţi remarcabile cu Java, dar şi deosebiri, datorate în principal faptului că în C++ pointerii se manifestă cu foarte multă vigoare în cele mai neaşteptate contexte), reprezentarea structurilor de prelucrare (din nou, similarităţi remarcabile între C++ şi Java), operaţiile I/O relative la consola sistem, din perspectivă C, precum şi suportul C pentru lucrul cu fluxuri, dacă se doreşte acest lucru. Nu voi spune decât următoarele: C++ este un superset al limbajului C; compilatoarele de C++ sunt realizate astfel încât toate enunţurile din C sunt acceptate, dar ele recunosc şi o varietate mare de enunţuri specifice modului de lucru în orientarea pe obiecte. După cum rezultă din titlul acestui paragraf, în atenţia mea se vor afla enunţurile tipice programării orientate pe obiecte în C++ sau Java. Înainte de a ajunge la aceste enunţuri, trebuie să facem primii paşi în învăţarea spiritului orientării pe obiecte. Voi prezenta, în continuare conceptele fundamentale de care “ne lovim” frecvent când programăm orientat pe obiecte. Spuneam în Capitolul 1 că, din perspectivă orientată pe obiecte, sistemul pe care îl modelăm va fi întotdeauna abstractizat de o colecţie de tipuri de obiecte, între care există anumite relaţii. Să ne imaginăm, de exemplu, că vrem să modelăm lumea poligoanelor astfel încât să putem oferi suport pentru învăţarea asistată de calculator a proprietăţilor poligoanelor. Există o mare diversitate de poligoane. Chiar şi cele care sunt de maxim interes din punct de vedere al predării/învăţării în şcoală, sunt suficient de multe pentru a pune probleme de abordare a prezentării proprietăţilor lor. Şi în acest caz, ca în oricare altul, la început avem în faţa ochilor realitatea de modelat, care poate fi sau nu structurată după legile ei naturale. Pentru un individ cu pregătire matematică adecvată este evident că obiectele din Figura 4 sunt clasificate aprioric. Eventual, putem spune că lipsesc unele tipuri de poligoane, pentru că inventarul făcut de noi în Figura 4 este incomplet.
Figura 4. Diferite tipuri de poligoane, aşa cum se pot întâlni, în realitatea modelată, prin reprezentanţi Probleme noastră nu este de a stabili nişte frontiere înăuntrul cărora să avem obiecte de acelaşi tip, ci de a spune care sunt obiectele care nu ne interesează. Nu se întâmplă, întotdeauna, aşa. Există probleme în care efectiv trebuie să depunem eforturi pentru a clasifica obiectele. Operaţia de clasificare presupune identificarea unor categorii de obiecte, apelând, simultan la omiterea unor detalii, socotite nesemnificative, pentru a obţine efectul de similaritate în procesul de caracterizare a obiectelor. Dacă în atenţia noastră se află problema clasificării poligoanelor, atunci, dacă în caracterizarea unui poligon reţinem atribute precum: lista coordonatelor vârfurilor, definiţia(), aria(), perimetrul(), atunci rezultatul clasificării este o clasă de obiecte pe care o putem numi clasa Poligon. Astfel că putem da definiţia de mai jos. Definiţia 1 Se numeşte clasă o colecţie de obiecte care partajează aceeaşi listă de atribute informaţionale şi comportamentale. Prin urmare, primul concept important cu care ne întâlnim în programarea orientată pe obiecte este conceptul de clasă. Rezolvarea orientată pe obiecte a unei probleme se bazează esenţial pe abilitatea specialistului (în cazul nostru programatorul) de a descoperi care sunt clasele pe baza cărora se poate construi soluţia. Presupunând că avem şi noi această abilitate şi, în acord cu criteriile proprii de clasificare (reflectate şi în inventarul din Figura 4), obţinem următoarea colecţie de clase candidate la obţinerea soluţiei problemei noastre.
Clasa patrulaterelor
Clasa triunghiurilor
Clasa hexagoanelor
Figura 5. Clasele candidate la obţinerea soluţiei pentru problema modelării poligoanelor Departe de mine ideea că am dat o soluţie definitivă problemei clasificării poligoanelor. Am prezentat, însă, o soluţie tributară unei anumite viziuni. În conformitate cu această viziune, singurele poligoane care prezintă interes pentru noi sunt triunghiurile, patrulaterele şi romburile. Figura 5 ne atrage atenţia, explicit, asupra diversităţii tipologice a patrulaterelor, fapt care evidenţiază necesitatea recurgerii şi la alt operator decât clasificarea pentru a gestiona această diversitate. Situaţia este, oarecum asemănătoare şi în cazul triunghiurilor, dar nu am subliniat explicit acest lucru. Făcând abstracţie de aceste elemente, deocamdată, să revenim asupra problemei care ne interesează cel mai mult în acest moment: cum stabilim proprietăţile unei clase? Regulile de bază în stabilirea proprietăţilor unei clase sunt următoarele: Lista atributelor informaţionale ale unei clase este, întotdeauna, rezultatul unui compromis între necesitatea unui maximum de informaţii despre obiectele clasei respective (informaţii,
care, în fond, caracterizează starea obiectelor din clasa respectivă) şi necesitatea unui minimum de redundanţe acceptate. Obiceiul unor programatori de a supradimensiona lista atributelor unei clase pe motiv că “mai bine să fie decât să le ducem lipsa”, nu este un model de urmat, nici atunci când există memorie “cu carul”. Odată specificate, atributele trebuie declarate ca fiind resurse private ale clasei, folosind sintaxa specifică limbajului pentru ascunderea unei resurse. Dogma orientării pe obiecte în legătură cu lista atributelor este că acestea sunt accesibile, diferitelor categorii de clienţi, în setare ca şi în consultare, prin intermediul unor metode speciale de setare(numite şi modificatori) sau consultare(numite şi selectori), cărora li se mai adaugă metode speciale implicate în crearea obiectelor unei clase (constructorii), respectiv, eliminarea acestor obiecte (destructorii). Evident, mai există şi alte tipuri uzuale de metode, precum iteratorii sau indexatorii, cărora li se acordă o atenţie specială în C#. Odată specificată lista atributelor informaţionale se poate trece la specificarea listei operaţiilor clasei, listă care abstractizează comportamentul clasei. În procesul de specificare a comportamentului unei clase trebuie avute permanent în vedere cele două dimensiuni ale comportamentului unei clase: comportamentul reclamat de gestiunea stării obiectelor (crearea lor, setarea valorilor atributelor, modificarea valorilor atributelor, consultarea valorilor atributelor, distrugerea obiectelor) precum şi comportamentul reclamat de relaţia clasei în cauză cu alte clase. Lista operaţiilor unei clase, la nevoie, poate fi organizată, din punct de vedere al modului de acces la aceste operaţii. Un singur lucru este general valabil în această privinţă: faptul că orice clasă trebuie să afişeze o listă cu operaţiile publice, care asigură accesul clienţilor la serviciile oferite de clasă. Lista acestor operaţii se numeşte, în mod normal, interfaţa clasei. Atenţie, cititorule! Când specifici resursele unei clase, eşti preocupat să spui ce fac obiectele clasei respective, omiţând intenţionat cum face clasa ceea ce trebuie să facă. Aşadar, nu strică să facem o distincţie necesară între definirea unei clase (= specificarea atributelor şi a operaţiilor) şi implementarea clasei (= scrierea codului asociat operaţiilor clasei). Definirea răspunde unor comandamente externe (ce ţin de modul de utilizare a obiectelor clasei); implementarea răspunde unor comandamente
care ţin de intimitatea comportamentului obiectelor (mod de reprezentare în memorie a obiectelor, mod de implementare a operaţiilor în acest context). Să mai adăugăm că o operaţie implementată se mai numeşte şi metodă. Folosind notaţia UML pentru reprezentarea vizuală a proprietăţilor unei clase, avem situaţia din Figura 6. Un concept, inevitabil în programarea orientată pe obiecte este şi conceptul de obiect. L-am folosit, deja, la modul intuitiv, ca fiind o parte a unei realităţi având o anumită valoare de întrebuinţare în contextul în care apare. Acum este momentul să dăm următoarea definiţie. Definiţia 2. Se numeşte obiect o instanţă a unei clase. De la teoria generală a tipurilor de date, se ştie că instanţa unui tip de dată este o variabilă având o anumită reprezentare în memorie, deci o identitate, şi o anumită stare din punct de vedere al conţinutului memoriei asociate. În toate limbajele de programare, care oferă suport orientării pe obiecte, clasa este asimilată unui tip de dată (este drept un tip special), care devine folositor în momentul în care se manifestă prin intermediul instanţelor. Instanţele unei clase se vor numi, aşadar, obiecte sau, uneori, variabile obiect. Dogmatic vorbind, dacă soluţia unei probleme este modelată ca o singură clasă, atunci ne aşteptăm ca dinamica aplicaţiei corespunzătoare să fie opera comportamentului unei instanţe a clasei. Ce se întâmplă, însă, dacă soluţia unei probleme este abstractizată de o ierarhie sau de o reţea de clase? În acest caz, dinamica aplicaţiei este opera colaborării între instaţele unora dintre clasele ierarhiei sau reţelei în cauză. Semantic vorbind, modul în care colaborează mai multe obiecte pentru a rezolva o anumită problemă, este greu de fixat într-o formulă definitivă. Din punct de vedere tehnic, însă, rezolvarea este relativ simplă, după cum se poate deduce şi din Figura 7. <Nume clasă> Lista atributelor informaţionale, specificate prin nume, tip şi eventual valoare implicită Lista operaţiilor, specificate prin signatură Figura 6. Notaţia UML pentru o clasă
Obiect
(Produs) CodProd:112233
ListareFurn(CodProd)
(Furnizor) AdresaListaFurn: XXXXXXXX
Clasa definitoare a obiectului Produs char codprod[11]; :
void afisare(); :
Furnizor
ListaFurn *AdresaListaFurn; : ListareFurn(char codp[11]) :
Operaţia afisare() apelează operaţia ListareFurn() Figura 7. Exemplu de comunicare(colaborare) între două obiecte Notaţia UML7 pentru o clasă şi înţelesul complet al noţiunii de signatură8 pot fi urmărite, luând-o înaintea timpului, consultând lucrarea [Iniţiere în modelarea obiect orientată utilizând UML, Dorin Bocu, Editura Albastră, Cluj-Napoca, 2002] În Figura 7 se prezintă schema fundamentală pentru colaborarea dintre obiecte. Unul dintre obiecte (obiectul de tip Produs, în cazul nostru) iniţiază comunicarea cu celălalt obiect (de tip Furnizor, în cazul nostru). Se mai obişnuieşte să se spună că obiectul de tip Produs i-a trimis un mesaj obiectului de tip Furnizor. Este de aşteptat ca, într-o formă sau alta, mesajul să fie urmat de un răspuns. Aşadar, dacă OFurnizor este o variabilă de tip Furnizor şi dacă această variabilă este accesibilă unui obiect de tip Produs, 7
UML-prescurtare de la Unified Modeling Language-Limbaj de modelare unificat, specificat de Rational Software Corporation şi omologat ca standard de facto de către grupul OMG 8 Signatura cuprinde, în genere: numele opreraţiei, lista de parametri şi, opţional, tipul returnat
atunci comunicarea este posibilă, cu condiţia ca obiectil de tip Produs să cunoască interfaţa clasei definitoare a obiectului OFurnizor şi să aibă acces la această interfaţă. În varianta cea mai simplă, un mesaj are structura: <Obiect>.<Nume_metodă>([<Lista_de parametri_actuali>]); ceea ce ne îndreptăţeşte să dăm definiţia de mai jos. Definiţia 3. Se numeşte mesaj apelul unei metode a unui obiect, apel efectuat de către un client potenţial al obiectului în cauză. Cunoaşterea acestui fapt este deosebit de importantă în situaţia în care vrem să explicăm semantica polimorfismului în programarea orientată pe obiect, ceea ce vom face în partea următoare a acestui capitol. În sfârşit, să mai menţionez faptul că răspunsul pe care îl dă un obiect când primeşte un mesaj de la alt obiect depinde de starea în care se află obiectul care primeşte mesajul. Este un aspect asupra căruia programatorul trebuie să fie atent, deoarece o stare improprie a obiectului destinatar, poate provoca eşuarea iniţiativei obiectului expeditor de a comunica. Multe excepţii apar, în faza de testare a programelor, tocmai pentru că nu s-a manifestat suficientă preocupare pentru evitarea utilizării obiectelor atunci când acestea sunt într-o stare critică (referinţe nerezolvate, resurse necesare insuficiente, etc.). Fie, în continuare, definiţia conceptului de stare. Definiţia 4. Se numeşte stare a unui obiect o abstractizare a valorilor atributelor acelui obiect precum şi a relaţiilor pe care obiectul le are cu alte obiecte. Aşadar, recapitulând, conceptele esenţiale cu care operăm în lumea orientării pe obiecte sunt: clasă, obiect, stare obiect, mesaj.
2.2 Principii în programarea orientată pe obiecte Am văzut în paragraful 2.1 care sunt conceptele cele mai importante cu care ne întâlnim în programarea orientată pe obiecte. Se ştie de la alte discipline exacte că, fără principii de utilizare a lor, conceptele sunt bune doar de pus în raft, ca nişte bibelouri cu care putem aminti posterităţii de o lume dispărută. Conceptele prind viaţă, cu adevărat, doar în momentul în care sunt acompaniate de un set de principii care fixează regulile esenţiale de utilizare a conceptelor. Evident, vom vedea care sunt aceste principii. Ce ne mai aşteaptă dincolo de ele? Ne aşteaptă invitaţia de a proba singuri valabilitatea acestor
principii, la început, improvizând cu inerentă stângăcie, mai apoi descoperind adevărate şabloane de rezolvare a unor probleme tip. Ne stă la dispoziţie, în cantităţi industriale, în internet, experienţa tuturor celor care şiau făcut o religie din orientarea pe obiecte şi au realizat aplicaţii de referinţă în acest spirit. Voi prezenta, în continuare, principiile pe care le consider absolut necesare pentru a programa în spiritul orientării pe obiecte. Abstractizarea Obişnuiesc să insist pe importanţa acestui principiu deoarece mânuirea lui fără inspiraţia necesară (iar inspiraţia, într-un domeniu, are cunoaşterea acelui domeniu ca înaintemergător) poate pune lesne sub semnul întrebării calitatea unei soluţii. Abstractizarea este procesul de ignorare intenţionată a detaliilor nesemnificative şi de reţinere a proprietăţilor definitorii ale unei entităţi. Prin urmare, din perspectivă procesuală, abstractizarea este o modalitate de a reflecta asupra proprietăţilor unei entităţi, cu scopul de a obţine reprezentări care descriu comportamentul entităţii, reprezentări care pot îndeplini simultan funcţii explicative, funcţii modelatoare şi, de ce nu, funţii demiurgice, la diferite paliere de profunzime. Utilitatea unei abstracţii se manifestă atunci când apar beneficiari în sfera ei de manifestare. Ca un exemplu, referindu-ne la limbajele de programare, putem observa că, toate limbajele oferă suport specific pentru abstractizare. Cu cât suportul pentru abstractizare este mai consistent, cu atât putem spune că avem de-a face cu un limbaj de programare de nivel mai înalt. In cazul limbajelor de programare, abstractizarea înseamnă un anumit gen de apropiere de limbajul uman şi, prin aceasta, de gândirea umană. De asemenea, la nivelul limbajelor de programare vorbim despre trei tipuri fundamentale de abstracţii, ca rezultate ale procesului de abstractizare: abstracţiile procedurale, abstracţiile la nivelul datelor şi clasele. Abstracţiile procedurale sunt cel mai mult folosite în programare. Utilizarea lor metodică permite ignorarea detaliilor legate de desfăşurarea proceselor. Toate funcţiile puse la dispoziţia programatorilor în C prin intermediului sistemului de fişiere antet sunt exemple de abstracţii procedurale, a căror utilizare este uşor de învăţat dacă le cunoaştem: numele, eventual lista de parametri şi/sau tipul returnat, plus semantica operaţiilor realizate de respectivele abstracţii. Nu trebuie să avem, neapărat, informaţii despre implementarea acestor funcţii. Care este câştigul? Se crează, la un anumit nivel de abstractizare a unui program, posibilitatea ca acesta să fie exprimat ca o succesiune de operaţii logice şi nu în termeni de instrucţiuni
primare ale limbajului. Pentru lizibilitatea programului şi, în consecinţă, pentru depanare, aşa ceva este de maxim interes. Abstracţiile la nivelul datelor permit, de asemenea, ignorarea detaliilor legate de reprezentarea unui tip de date, în beneficiul utilizatorilor tipului de date. Un exemplu remarcabil de astfel de abstracţie la nivelul datelor este tipul variant, specificat de cei de la firma Borland în cadrul limbajului Object Pascal. Cei care au realizat aplicaţii Delphi şi “au dat cu nasul” peste tipul variant au probabil amintiri plăcute despre versatilitatea şi uşurinţa în utilizare a acestui tip de dată. Clasele ca abstracţii combină într-o nouă abstracţie, extrem de puternică şi polivalentă semantic, cele două abstracţii mai sus pomenite, care ţin de acea epocă din istoria programării în care deviza era: “Algoritmi+structuri de date=programe”. Arsenalul pus la dispoziţia programatorului de clase, în calitate de instrumente de abstractizare va fi în atenţia acestui curs, în continuare. Încapsularea Principiul încapsulării insistă pe separarea informaţiilor de manipulare a unei entităţi de aspectele implementaţionale. Se deduce, cu uşurinţă, faptul că încapsularea este o varietate de abstractizare. Practicată metodic şi cu suport sintactic adecvat, în programarea orientată pe obiecte, încapsularea este susţinută şi la alte nivele, de către limbaje de programare diferite. De exemplu, conceptul de unit din Object Pascal, permite încapsularea resurselor unei aplicaţii Delphi, ceea ce, în fond, înseamnă modularizare, la un nivel de abstractizare mai înalt decât cel specific încapsulării la nivel de clase. Cuvintele cheie pe care se sprijină operaţionalizarea principiului încapsulării sunt interfaţa şi implementarea. Motivul pentru care se insistă atât pe acest principiu este simplu: separând interfaţa de implementare şi admiţând că interfaţa este foarte bine structurată (rezultă că este stabilă în timp şi acceptată de utilizatori din punct de vedere al utilităţii şi comodităţii în utilizare) înseamnă că eventuale modificări ale implementării (absolut fireşti în condiţiile îmbunătăţirii permanente a mediilor de execuţie şi programare) nu vor putea afecta utilizatorii. Este bine ca cititorul să facă distincţie între încapsulare şi localizarea datelor şi a funcţiilor, în cadrul aceleeaşi entităţi. Încapsularea are nevoie de localizare pentru a sublinia caracterul de black-box al entităţilor, dar ea înseamnă mult mai mult decât atât. De asemenea, nu trebuie să fetişizăm încapsularea, aşteptând de la ea să garanteze siguranţa în procesul de manipulare a obiectelor. Cât de sigur în utilizare este un sistem soft, hotărăşte programatorul, care combină eficient forţa principiului încapsulării cu
procedeele tehnice de asigurare a protecţiei faţă de ingerinţele cu efecte negative. Aşadar, din perspectiva încapsulării, o clasă, indiferent de limbajul în care va fi implementată, trebuie să aibă, obligatoriu, două compartimente, ca în Figura 8.
Student
Date şi operaţii private
(Implementarea) Operaţii publice
(Interfaţa)
Figura 8. Componentele obligatorii ale unei clase, din perspectiva încapsulării Moştenirea Multă vreme, moştenirea a fost un vis pentru a cărui îndeplinire, într-o formă destul de primitivă, programatorii trebuiau să depună eforturi intense. Apariţia limbajelor care oferă suport sintactic pentru operaţionalizarea principiilor orientării pe obiecte (OO), a confirmat, printre altele şi marea importanţă a principiului moştenirii pentru specificul unei soluţii OO. Din perspectivă mecanică privind lucrurile, acest principiu afirmă posibilitatea ca o clasă B să moştenească o parte dintre proprietăţile unei clase A. În acest fel avem la dispoziţie un mecanism practic pentru gestiunea similarităţilor, naturale, de altfel într-o societate de obiecte foarte diversificată. În paragraful 2.1 (Figura 4) am prezentat, deja, un exemplu de societate posibilă de obiecte, în care diversitatea punea probleme de clasificare, lăsând deschisă problema gestiunii similarităţilor, în cazul mulţimii patrulaterelor. Mergând pe linia utilizării notaţiei UML pentru reprezentarea unei soluţii OO, atunci trebuie să spunem că dacă avem o clasă B care moşteneşte o parte dintre proprietăţile clasei A, acest lucru va fi redat grafic în felul în care este arătat în Figura 9.
SPECIALIZARE
GENERALIZARE
A
Clasa părinte, superclasa, clasa de bază Simbolul care indică relaţia de generalizare dintre clasele A şi B
B
Clasa copil, subclasa, clasa derivată
Figura 9. O parte din semantica relaţiei de moştenire care operează între clase După cum se vede, relaţia de moştenire nu este doar o relaţie al cărei înţeles se reduce la posibilitatea ca B să moştenească o parte din proprietăţile lui A; semantica relaţiei de moştenire este mult mai subtilă. Mai întâi, este vorba despre necesitatea de a reflecta cu îndemânarea necesară la cele două posibilităţi de a identifica o astfel de relaţie: procedând top-down sau bottom-up, deci specializând, după ce am identificat o clasă rădăcină, sau generalizând, după ce am terminat operaţia de clasificare a claselor şi am început să organizăm clasele în familii, după similarităţile care le leagă. Indiferent de abordare, rezultatul trebuie să fie acelaşi: o ierarhie de clase, în care similarităţile sunt distribuite pe nivele de abstractizare, reducând astfel la minimum redundanţele şi pregătind terenul pentru o reutilizare elegantă a codului şi pentru o serie de alte avantaje care însoţesc un lanţ de derivare. Exemplificăm cu ierarhia din Figura 10, a cărei semantică ne este deja cunoscută. În Figura 10 regăsim aproape toate elementele specifice unei soluţii obiect orientate, care foloseşte judicios principiul moştenirii. Astfel, aproape toate soluţiile orientate pe obiect au o clasă rădăcină (care, în anumite situaţii poate fi o clasă abstractă, adică o clasă care nu poate avea instanţe directe, dar referinţe având tipul ei pot fi asociate cu instanţe ale descendenţilor), dacă soluţia se reduce la o singiră ierarhie de clase. În ierarhie putem întâlni şi clase intermediare, precum şi clase terminale sau frunză.
Poligon
Triunghi
Clasă rădăcină, poate fi şi abstractă
Patrulater
Paralelogram
Trapez
Clasă intermediară
Patrulater inscriptibil
Clase frunză Romb
Dreptunghi
Figura 10. Principiul moştenirii în acţiune Din punctul de vedere al programatorului, pe lângă utilitatea moştenirii în procesul de gestiune a similarităţilor, mai există un avantaj care poate fi exploatat în faza de implementare a soluţiei, avantaj derivat din principiul moştenirii sub forma unui alt principiu care afirmă că: Orice părinte poate fi substituit de oricare dintre descendenţi Acest principiu este de mare utilitate pentru programatori, el fiind operaţionalizat prin intermediul operaţie de casting, aplicată obiectelor ale căror clase definitoare sunt în relaţie de moştenire. Evident, este vorba de un casting implicit în lumea obiectelor (descendentul moştenind tot ceea ce se poate de la strămoşi, va putea opera în locul strămoşului). Castingul de acest tip se numeşte up-casting. Se practică, explicit şi down-castingul, dar programatorul trebuie să fie pregătit să răspundă de consecinţe. Într-un downcasting, este posibil ca obiectul de tip strămoş, convertit la un obiect de tip descendent, să nu asigure coerenţa informaţională de care are nevoie descendentul, deci pot apare, principial, excepţii. În Java se întâlnesc amândouă tipurile de casting. În sfârşit, în Figura 10 avem un exemplu de utilizare a moştenirii simple, potrivit căreia un descendent poate avea un singur strămoş. Unele limbaje de
programare(C++, de exemplu) acceptă şi moştenirea multiplă, ceea ce înseamnă că o clasă poate avea doi sau mai mulţi strămoşi. O astfel de soluţie pentru principiul moştenirii este plină de riscuri, cu toate avantajele pe care le presupune. Moştenirea multiplă poate genera ambiguităţi care pot pune la încercare răbdarea programatorului.
A
B
C
D Figura 11. Exemplu de moştenire multiplă În exemplul din Figura 11, clasa D are ca strămoşi clasele B şi C. Obiectele clasei D vor avea moştenite resursele lui A, atât pe traseul lui B cât şi pe traseul lui C. Ambiguitatea referirii la o resursă a lui A în cadrul unei instanţe a lui D este evidentă. Când este considerată strict necesară, moştenirea multiplă se utilizează cu discernământ. Java şi C# nu mai promovează moştenirea multiplă, propunând alte soluţii la această problemă. Moştenirea nu este doar o problemă de sintaxă, ci este esenţa însăşi a programării orientate pe obiecte. Pe temeiul moştenirii are rost să vorbim în continuare de principiul polimorfismului, aplicat la lumea obiectelor. Polimorfismul După cum se vede şi în exemplul prezentat în Figura 10, ierarhia de clase care modelează, la urma urmei, comportamentul unei aplicaţii poate avea mai multe clase frunză, deci clase care pote genera instanţe. Crearea unei instanţe este, indicutabil o problemă în care programatorul trebuie să se implice, el trebuind să stabilească, în funcţie de dinamica aplicaţiei, ce constructor va coopera la crearea unei instanţe. În ideea că avem o aplicaţie care se ocupă de simularea învăţării poligoanelor, vi se pare interesant să declarăm câte o
variabilă pentru fiecare tip de poligon din ierarhia prezentată în Figura 10 (mai puţin clasa rădăcină)? Nu este interesant din două motive: 1. 2.
Explozia de variabile obiect nu este un indiciu de performanţă în programare. Odată create obiectele, programatorul trebuie să ştie în orice moment ce fel de obiect lucrează la un moment dat. Chiar că aşă ceva poate deveni supărător într-o aplicaţie de mare complexitate.
Nu ar fi mai civilizat să declarăm o variabilă de tip Poligon în care să păstrăm instanţe create cu ajutorul constructorilor oricăror descendenţi care pot avea urmaşi direcţi? Admiţând că, în clasa Poligon, am declarat o operaţie calculArie() care este suprascrisă în fiecare descendent, atunci situaţia creată este următoarea: obiectul creat cu ajutorul constructorului unui descendent al clasei Poligon şi păstrat într-o variabilă de tip Poligon (principiul substituţiei îngăduie acest lucru), să-i spunem p, va putea să apeleze diferite implementări ale operaţiei calculArie(), în funcţie de contextul în care se crează p. Aşadar, un mesaj de tipul p.calculArie() ce răspuns va genera? Răspunsul va fi dependent de context, controlul contextului (adică alegerea metodei specifice clasei definitoare a obiectului păstrat în p) realizându-se de către sistem prin mecanisme specifice, în timpul execuţiei programului. Acest mod de legare a unui nume de operaţie de codul metodei specifice tipului definitor al variabile obiect gazdă se numeşte late binding. Este un mod de legare mai puţin performant decât legarea statică (la compilare), dar avantajele scuză dezavantajele insignifiante, în condiţiile în care viteza procesoarelor şi disponibilul de RAM sunt în expansiune permanentă. S-a înţeles, probabil, faptul că polimorfismul este de neconceput fără moştenire în care specializarea claselor să se bazeze şi pe suprascrierea unor metode în clase aflate în relaţie de moştenire. Tot ceea ce trebuie să ştie programatorul de aici încolo este avertismentul că: Moştenirea şi polimorfismul sunt ideale ca uz şi contraindicate ca abuz. Terminând excursia în lumea conceptelor şi a principiilor programării orientate pe obiecte, nu ne rămâne decât să anunţăm că în capitolele următoare vom încerca să vedem aceste abstracţii la lucru în context Java sau C++.
Capitolul 3 Specificarea Ĺ&#x;i implementarea unei clase ď&#x192;&#x152; Perspectiva Java
3.1 În loc de introducere Fără să am pretenţia că am epuizat semantica modelării orientate pe obiect, în capitolele precedente, în acest capitol voi începe incursiunea în lumea elementelor suport oferite de Java şi C++ pentru implementarea orientată pe obiect a modelelor obiect orientate. Laboratoarele de cercetare şi lumea practicienilor văd, încă, în aceste două limbaje nişte momente de referinţă în zbuciumata evoluţie a limbajelor de programare. Apariţia limbajului C# la orizont, „prin bunăvoinţa” celor de la Microsoft, se anunţă un concurent redutabil, care, îşi propune să câştige, deopotrivă, atenţia fanilor Java şi C++. Până ce apele se vor limpezi, mă voi ocupa de ceea ce, tocmai, am anunţat în lista subiectelor care fac obiectul acestui capitol.
3.2 Atenţie la importanţa efortului de abstractizare! Voi încerca să arăt cum se specifică şi implementează clasele în Java. În tot acest timp, încercaţi, împreună cu mine, să nu minimalizaţi nici o clipă importanţa hotărâtoare a abstractizării în elaborarea unor soluţii stabile şi flexibile9. În acest scop voi considera un exemplu pretext de problemă prin intermediul căreia voi încerca să pun în valoare puterea de reprezentare a limbajelor Java şi C++. Să se scrie codul Java / C++ care pune la dispoziţia unor utilizatori potenţiali capabilităţi de lucru cu liste simplu înlănţuite de numere întregi, pentru care resursele necesare reprezentării sunt alocate dinamic. În continuare mă voi referi la această problemă cu acronimul LSI. De ce numai acest tip de listă? De ce doar numere întregi? Pentru că aşa cere utilizatorul în acest moment. Nici mai mult, nici mai puţin. Aşa se întâmplă şi în viaţa de toate zilele. Specialiştii în IS trebuie să livreze beneficiarilor ceea ce aceştia se aşteaptă să primească. Amorul artei sau dispreţul faţă de beneficiar, sunt taxate necruţător de către ansamblul regulilor jocului din industria de soft. Nu voi exagera cu descrierea travaliului conceptual în urma căruia am ajuns la nişte concluzii în legătură cu rezolvarea problemei LSI. Dar, câteva elemente de descriere a atmosferei trebuie, totuşi precizate. Se ştie că structurile dinamice de date sunt preferate structurilor statice de date, atunci când utilizarea chibzuită a memoriei şi flexibilitatea Nu este o contradicţie între termeni. Cele mai bune modele, într-o lume care nu stă pe loc, sunt modelele care pot fi socotite, în acelaşi timp, închise şi deschise, deci stabile şi flexibile. 9
relaţiilor dintre obiectele care populează structura sunt critice pentru calitatea unui sistem soft. Se mai ştie, totodată, că o structură dinamică de date este complet specificată dacă am clarificat: Tipul datelor care populează structura. Mecanismul de înlănţuire a datelor din structură. Punctele de intrare în structură. Operaţiile cu ajutorul cărora întreţinem şi consultăm structura. Fiecare din cerintele de mai sus, poate face obiectul unor consideraţii cu implicaţii interesante asupra soluţiei. Mă limitez doar la a observa faptul că o structură de date dinamică este un exemplu reprezentativ de tip de dată care poate fi modelat orientat pe obiecte, în aşă fel încât serviciile oferite să fie complete şi uşor de apelat. Intrând puţin în domeniul problemei, dacă ne gândim la o listă simplu înlănţuită, semantica ei poate fi vizualizată, parţial, ca în Figura 12. Adresa de start a primului nod Data_ 1
Data_2
Informaţe nod Legătura spre următorul nod
Data_n
Ultimul nod nu are un succesor
Figura 12. Incercare de vizualizare a unei liste simplu înlănţuite Prin urmare, obiectele care fac parte din inventarul problemei sunt: Nod (având ca proprietăţi informaţionale Data şi Adresa de legătură cu următorul element iar ca proprietăţi comportamentale, cel puţin operaţii legate de crearea unui nod şi consultarea proprietăţilor lui informaţionale) şi Lista (un obiect care, în principiu este o agregare10 de obiecte de tip Nod). In modelarea obiect orientată se foloseşte relaţia de agregare pentru a indica o asociere între obiecte care, din punct de vedere semantic, sunt într-o relaţie parte-întreg. Reprezentarea unei relaţii de agregare este la latitudinea programatorului. 10
Un obiect care poartă marca tipului Lista va avea, aşadar, proprietăţi informaţionale şi comportament specific. Abstractizând cu înverşunare, lista proprietăţilor informaţionale ale unui obiect de tip Lista ar putea să conţină doar adresa primului nod din listă. Însă, dacă trecem în revistă exigenţele clienţilor faţă de un obiect de tip Lista, vom descoperi că este indicat să avem un atribut pentru a indica dacă lista conţine sau nu elemente (un exemplu de redundanţă în procesul de utilizare a memoriei interne care asigură o viteză sporită în procesul de utilizare a unei liste). Dacă acest atribut este un întreg care indică chiar numărul elementelor din listă, cu atât mai bine. Aşadar, în notaţie UML, în acest moment am putea avea situaţia din Figura 13. Lista Nod -Int Data; -Nod Urmatorul;
+Nod(int Numar); +void setareData(int Numar); +int consultareData(); +void setareUrmator(Nod AUrm) +Nod consultUrmator()
-Nod Astart; -Nod AUltim; -Int NrElem; +void setareAStart(Nod Element); +Nod consultaAStart(); +void setareNrElem(int ne); +int consultaNrElem(); +setareAUltim(Nod Element) +Nod consultaAUltim(); +void insereazaDNod(Nod Element); +void insereazaINod(Nod Element); +void stergeNodAdr(Nod Element); +void stergeNodNum(int Nr); +salveazaInFisier(char numef[]); +incarcaDinFisier(char numef[]);)
Figura 13. Clasele candidate la rezolvarea problemei LSDI Ce observaţii putem face? Clasa Nod este o clasă concretă, adică, având constructor, poate avea instanţe. Să mai observăm faptul că atributele clasei Nod sunt prefixate cu câte un semn -, ceea ce, în UML, înseamnă că sunt private. Conform dogmei OO, absolut normal. De asemenea, să mai observăm că operaţiile clasei Nod sunt prefixate cu semnul + şi sunt scrise cu font drept, ceea ce, în UML, înseamnă că sunt publice şi au deja implementare valabilă. Semantica acestei clase şi a contextului în care operează s-ar părea că nu ne pretinde specificarea unor operaţii private sau protejate. Oare? Este adevărat că în Java, de exemplu, crearea unui obiect de tip Nod se va face rezonabil, chiar şi dacă nu am prevedea un constructor, dat fiind faptul că obiectele se crează şi în acest caz, atributele fiind iniţializate cu valori predefinite (0 pentru numere, null pentru obiecte, false pentru valori booleene, ’\u0000’ pentru caractere).
Dacă un astfel de comportament implicit nu este de acceptat, atunci este loc pentru a specifica operaţii care fac validarea stării obiectelor. Aceste operaţii ar putea fi folosite, ca uz intern, de orice altă operaţie care este sensibilă la starea obiectului care o foloseşte la un moment dat. Pe de altă parte, observăm faptul că unele dintre operaţiile clasei Lista sunt scrise cu caractere italice. În UML aceasta înseamnă că aceste operaţii sunt abstracte. Prin urmare, clasa Lista este o clasă abstractă, deci nu are constructor şi tipul introdus de această clasă nu va putea avea instanţe directe. Pentru a fi utilă, clasa Lista trebuie să aibă descendenţi. Care vor fi aceşti descendenţi? Vom considera că aceşti descendenţi sunt doi: o clasă care modelează o listă simplu înlănţuită de întregi, în care cuvântul de ordine la creare este ordinea fizică de introducere şi o clasă care modelează o listă simplu înlănţuită de întregi, în care elementele listei sunt introduse astfel încât, după fiecare introducere acestea să fie în ordine crescătoare. Pe scurt: liste indiferente la ordinea numerelor întregi şi liste sensibile la ordinea numerelor întregi. Vom obţine ierarhia din Figura 14. Lista
ListaOarecare
ListaSortata
Figura 14. Ierarhia claselor care modelează două varietăţi de listă simplu înlănţuită. Se poate observa că cele două varietăţi posedă operaţiile necesare pentru a simula, în caz de nevoie şi comportamentul unei stive (AStart este Top-ul iar inserareINod() şi stergeNodAdr(AStart) sunt operaţiile specifice unei stive, adică Push() şi Pop()). Cititorul bănuieşte că inserareINod() este abreviere de la „inserare înainte de nod” iar inserareDNod() este abtreviere de la „inserare după nod”. Consideraţii de acest gen şi chiar mai profunde, trebuie să prilejuiască orice încercare de rezolvare orientată pe obiect a unei probleme. 3.3 Specificarea şi implementarea unei clase în Java Este cunoscut faptul că, în Java, orice aplicaţie este puternic obiect orientată, cel puţin datorită cadrului sintactic obligatoriu pentru realizarea unui
applet sau a unei aplicaţii Java obişnuite. Dacă spiritul orientării pe obiecte este bine înţeles, atunci Java este o soluţie interesantă pentru multe probleme a căror rezolvare presupune realizarea unor aplicaţii pentru care lumea Internetului este puternic deschisă. Indiferent de tipul aplicaţiei, piesele de bază în realizarea acesteia sunt clasele. Vom avea, în cazul unei aplicaţii Java obişnuite, o singură clasă publică, care conţine funcţia main() şi una sau mai multe clase care modelează, la diferite nivele de rafinare, comportamentul aplicaţiei. De asemenea, în cazul unui applet Java, vom avea o singură clasă publică care extinde clasa Applet şi una sau mai multe clase care modelează, la diferite nivele de rafinare, comportamentul applet-ului. Privită de la cel mai înalt nivel de abstractizare, definiţia unei clase în Java este: [<Listă modificatori>] class <Nume clasă> [extends <Clasa de bază>] [implements <Lista interfeţe>] { //Listă de resurse sau corp clasă } Resursele unei clase sunt de două tipuri: atribute şi/sau operaţii. Problema noastră, după cum am văzut, este de a organiza aceste resurse, astfel încât să putem beneficia de o serie de avantaje din punct de vedere al efortului de dezvoltare cât şi din punct de vedere al calităţii softului11. În paragraful 2.2 am văzut că o încapsulare corectă (în acord şi cu dogma OO) înseamnă să declarăm ca private atributele şi să definim o interfaţă corespunzătoare clasei. Vom vedea, mai jos, ce înseamnă acest lucru în cazul problemei noastre. Atributele unei clase se specifică sintactic astfel: [<Listă modificatori atribut>] Tip <Lista identificatori variabile>; Operaţiile unei clase se specifică prin signatură şi implementare ca mai jos: [<Listă_modificatori_operaţie>]Tip_returnat Identificator_metodă> ([<Listă parametri>) [throws <Lista excepţii>] { <Corp operaţie> } Despre calitatea softului cititorul poate găsi elementele esenţiale în D. Bocu, Iniţiere în ingineria sistemelor soft, Editura Albastră, 2002 11
Formalizmul folosit pentru prezentarea sintaxei de definire a unei clase se bazează, după cum se poate deduce, pe următoarele convenţii: Orice construcţie a utilizatorului este prezentată între simbolurile < ...>. Orice construcţie opţională este încadrată de simbolurile [...]. Cuvintele rezervate sunt evidenţiate prin îngroşare. Întrebarea firească care se pune este următoarea: la ce ne folosesc aceste elemente de variaţie în procesul de definire a unei clase. Voi încerca să răspund pe rând la toate ramurile acestei intrebări. Modificatorii aplicabili claselor După cum se poate observa, cuvântul cheie class poate fi prefixat, opţional, de un modificator. Lista completă a acestor modificatori este: abstract, final, public. Modificatorul de clasă „abstract” Java permite extinderea unei clase existente cu o subclasă. Cu timpul, este posibil să vă constituiţi propriile biblioteci de clase care consideraţi că vor fi extinse de alţi programatori. Pentru unele clase, poate să fie inutil să implementaţi o operaţie cât timp nu se cunoaşte cum va fi extinsă clasa. În astfel de cazuri, puteţi utiliza cuvântul cheie abstract pentru a indica faptul că în descendenţi unele dintre operaţiile clasei trebuie să fie supradefinite obligatoriu. Clasa Lista din Figura 14 ar putea fi definită, prin urmare astfel, în Java: abstract class Lista { private Nod AStart; private Nod AUltim; private int NrElem; public void setareAStart(Nod Element); public Nod consultaAStart(); public void setaretNrElem(); public int consultaNrElem(); public Nod consultaAUltim(); public abstract void insereazaDNod(Nod Element); public abstract void insereazaINod(Nod Element); public abstract void stergeNodAdr(Nod Element); public abstract void stergeNodNum(int Nr); public final void salveazaInFisier(char numef[]); public final Nod incarcaDinFisier(char numef[]);)
} Este evident faptul că descendenţii ListaOarecare şi ListaSortata sunt obligaţi să implementeze toate operaţiile clasei Lista, care sunt abstracte. De asemenea, să observăm că implementarea definitivă a operaţiilor de salvare/restaurare a listei este realizată în clasa Lista şi va fi folosită fără modificări în descendenţi. Modificatorul de clasă „final” (tocmai l-am utilizat mai sus în alt context). În general vorbind, Java permite unei clase să extindă o altă clasă. Atunci când definim o clasă, în contextul în care anticipăm utilizarea ei, s-ar putea să nu dorim extinderea ei de către alte clase. În acest caz, prin includerea cuvântului cheie final în cadrul definiţiei clasei vom împiedica crearea de subclase ale clasei în cauză. Aşadar, clasa: public final class NumeClasa {...} nu va putea să aibă urmaşi. Modificatorul de clasă „public” Atunci când utilizăm cuvântul cheie public în cadrul declaraţiei clasei, ne asigurăm că acea clasă este vizibilă / accesibilă de oriunde. Dacă dorim să controlăm accesul la o clasă, cuvântul cheie public nu are ce căuta în declaraţia clasei. Să reamintim, totodată, faptul că Java permite o singură clasă publică într-un fişier cu cod sursă. Evident, caracterul public al unei clase poate avea alte conotaţii în contextul organizării codului sursă al unei aplicaţii cu ajutorul pachetelor. Mai precis spus, o clasă publică poate fi utilizată din exteriorul pachetului în care a fost declarată, pentru a crea instanţe sau pentru a o extinde. În schimb, o clasă care nu a fost declarată publică este considerată o clasa friend , putând fi accesată doar din interiorul pachetului în care este rezidentă. Modificatorii aplicabili atributelor Domeniul de valabilitate al unui atribut defineşte locaţiile din program în care atributul este cunoscut. În procesul de definire a unei clase putem controla domeniul unui atribut al clasei precedând declaraţia lui cu unul din cuvintele cheie: public, private, protected, static, final, tranzient, volatile. Să menţionăm faptul că un atribut care nu este însoţit de nici un modificator este vizibil friendly, adică doar din interiorul clasei şi din clasele din acelaşi pachet.
Modificatorul de atribut „public” Un atribut public este vizibil / accesibil oriunde este vizibilă / accesibilă clasa care îl conţine. Aşadar, pentru a declara ca public un atribut vom proceda ca mai jos: : public int vizibilOriundeEsteVizibilaClasa; : Dogma spune că o clasă care ajunge la client trebuie să-şi ascundă atributele faţă de acesta, accesul la ele fiind mijlocit de interfaţă, dacă este cazul. Nu este, însă, exclus ca o serie de clase neterminale (care nu sunt, deci clase frunză) să declare ca publice o parte a atributelor, protecţia lor fiind controlată, la nivelul descendenţilor prin intermediul interfeţelor sau al organizării în pachete. Modificatorul de atribut „private” Un atribut privat este vizibil numai în interiorul clasei sale. Subclasele şi clienţii externi nu pot accesa aceste atribute. Modificatorul de atribut „protected” Un atribut al clasei, declarat ca protejat, este accesibil în descendenţii clasei sau în cadrul pachetului din care face parte clasa deţinătoare. Atenţie, un atribut declarat ca protected într-o clasă va putea fi accesat în scriere şi citire în toţi descendenţii clasei în cauză, rezidenţi chiar şi în afara pachetului gazdă al clase care deţine atributul protejat. Nu va fi permis accesul direct la atributul protejat pentru clase care nu sunt descendeţi ai clasei care declară atributul protejat. Modificatorul de atribut „static” Orice atribut care nu este declarat ca static este numit atribut de instanţă, ceea ce înseamnă că fiecare instanţă are propria copie a atributului. Atunci când este în interesul comportamentului clasei ca un atribut să fie partajat de toate obiectele clasei în cauză, acel atribut va fi declarat ca static. Modificatorul de atribut „final” Atunci când în definiţia unei clase menţionăm un atribut final, indicăm compilatorului faptul că acel atribut are valoare constantă, care nu poate fi modificată de program. Iniţializarea atributului cu o valoare se poate face la crearea obiectlui gazdă, prin contribuţia constructorului sau în cadrul unei declaraţii de tipul: : protected static final int nr=10;
: Atenţie! Cele două metode de iniţializare sunt mutual exclusive.
Modificatorul de atribut „transient” Atunci când declarăm un atribut ca fiind de tip transient, indicăm compilatorului Java faptul că atributul nu este o parte permanentă a obiectului, deci de uz intern şi în cazul serializării, de exemplu, nu va fi salvat pe memoria externă. Un atribut tranzient se declară astfel: : private transient String password; : Modificatorul de atribut „volatile” Atunci când se compilează programul, compilatorul analizează codul şi, adeseori va efectua anumite manevre cu scopul de a optimiza performanţele codului. Atunci când dorim să scoatem un atribut de sub incidenţa unei astfel de eventualităţi, o declarăm ca volatilă. Practic, aceasta înseamnă că există situaţii particulare în care comunicaţia cu alte programe sau rutine necesită neintervenţia compilatorului asupra unui atribut, esenţial pentru buna desfăşurare a comunicaţiei în cauză. Modificatorii aplicabili operaţiilor În această secţiune vom prezenat o serie de modificatori care, de cele mai multe ori, sunt aplicabili metodelor care implementează operaţiile claselor. Aceşti modificatori sunt: public, private, protected, static, final, abstract, native, synchronized. Modificatorul de metodă „public” Semnificaţia acestui modificator, în cazul în care se aplică unei metode, este asemănătoare cu cea pe care o are când se aplică unui atribut. Modificatorul de metodă „private” Semnificaţia acestui modificator, în cazul în care se aplică unei metode, este asemănătoare cu cea pe care o are când se aplică unui atribut. Modificatorul de metodă „protected” Semnificaţia acestui modificator, în cazul în care se aplică unei metode, este asemănătoare cu cea pe care o are când se aplică unui atribut.
Modificatorul de metodă „static” Semnificaţia acestui modificator, în cazul în care se aplică unei metode, este, într-o oarecare măsură, asemănătoare cu cea pe care o are când se aplică unui atribut. Mai precis, trebuie să spunem că o metodă statică, pentru a fi utilizată nu reclamă neapărat o instanţă, putând fi utilizată şi printr-un apel de tipul: NumeleClasei. NumeleMetodeiStatice(parametri); O metodă statică poate fi utilizată pentru a accesa alţi membri statici ai clasei, dar, în nici un caz, pentru a accesa variabile nestatice. Modificatorul de metodă „final” Am văzut, deja, în cursul 2, că anumite metode ale claselor pot fi supradefinite în clasele descendente. Dacă, din variate motive, dorim să blocăm posibilitatea supradefinirii unei metode, atunci vom informa compilatorul de această intenţie declarând metoda ca finală astfel: public final void MetodaNuPoateFiSupradefinita(); Modificatorul de metodă „abstract” Dacă o metodă a unei clase este precedată de cuvântul cheie abstract, atunci compilatorul nu va autoriza crearea de instanţe ale clasei în cauză. Totodată, o clasă care extinde clasa în cauză va trebui să implementeze metoda abstractă obligatoriu. Declararea se face astfe: public abstract void implementareUlterioară(); Atenţie! O metodă abstractă nu poate fi privată sau finală. Semantica cuvântului cheie abstract (= metoda va fi implementată în descendenţi) vine în contradicţie cu semantica cuvintelor cheie private şi final care spun, în moduri diferite, că metoda nu poate fi modificată. Modificatorul de metodă „native” Acest modificator se utilizează pentru a spune compilatorului că o anumită metodă utilizează cod scris într-un alt limbaj de programare, cum ar fi C/C++, de exemplu. Această posibilitate trebuie folosită cu discernământ deoarece loveşte puternic în portabilitatea aplicaţiei, aşa cum este ea înţeleasă în Java. Modificatorul de metodă „synchronized” Se ştie că Java oferă suport pentru multitasking sub forma „un program mai multe fire de execuţie”. În funcţie de activitatea programului respectiv,
uneori este necesar să garantăm faptul că, două sau mai multe fire nu pot accesa simultan aceeaşi metodă. Prin urmare, pentru a controla numărul de fire de execuţie care pot accesa o metodă la un moment dat, utilizăm cuvântul cheie synchronized. Atunci când compilatorul Java întâlneşte o metodă prefixată de cuvântul cheie synchronized, introduce un cod special care blochează metoda când un fir începe execuţia instrucţiunilor metodei şi o deblochează când firul îşi încheie execuţia. În mod uzual, sincronizarea metodelor este reclamată de necesitatea partajării datelor. Am prezentat, mai sus, semantica cuvintelor cheie ascunse sub sintagma „modificatori”, aplicaţi atributelor sau operaţiilor. Sintaxa care stă la baza definirii unei clase mai conţine, opţional şi cuvântul cheie extends pentru a indica o clasă de bază clasei în curs de definire. De asemenea, în sintaxa prezentată se mai evocă şi eventualitatea apariţiei cuvântului cheie implements urmat de numele interfeţelor pe care le implementează clasa, atunci când este cazul. Evident, cuvântul cheie extends este legat de problematica moştenirii în programarea orientată pe obiecte în Java iar cuvântul cheie implements este legat de problematica utilizării interfeţelor, pentru a introduce numeroase elemente de flexibilitate în programarea Java, inclusiv rezolvarea moştenirii multiple, neadmisă direct în Java. Despre aceste două probleme vom discuta pe îndelete in Capitolul 5. Înainte de a trece la prezentarea cadrului C++ pentru definirea unei clase putem urmări, mai jos, codul Java asociat definirii claselor Nod şi Lista, aşa cum apar ele în lumina observaţiilor făcute până acum. //--------------------------------------------//Cod Java care demareaza procesul de rezolvare //a problemei LSI //--------------------------------------------//Specificare minimala a clasei Nod //Implementare clasa Nod class Nod { private int Data; private Nod Urmatorul; //constructor public Nod(int Numar) { Data=Numar; };
//modificator atribut Data public void setareData(int nr) { Data=Numar; }; //selector atribut Data public int consultareData() { return Data; }; //Modificator atribut Urmatorul public void setUrmator(Nod Element) { Urmatorul=Element; }; //Selector atribut Urmatorul public Nod consultaUrmator() { return Urmatorul; }; }; //Specificare aproape completa a clasei abstracte Lista abstract class Lista { private Nod AStart; private Nod AUltim; private int NrElem; //Modificator atribut AStart public void setareAStart(Nod Element) { AStart=Element; }; //Selector atribut AStart public Nod consultaAStart() {
return AStart; }; //Modificator atribut NrElem public void setareNrElem(int ne) { NrElem=ne; }; //Selector atribut NrElem public int consultaNrElem() { return NrElem; }; //Modificator atribut AUltim public void setareAUltim(Nod Element) { AUltim=Element; }; //Selector atribut AUltim public Nod consultaAUltim() { return AUltim; }; //Metoda abstracta //Va fi implementata in descendenti //Insereaza un element dupa ultimul nod introdus public abstract void insereazaDNod(Nod Element); //Metoda abstracta //Va fi implementata in descendenti //Insereaza un element inaintea ultimului nod introdus public abstract void insereazaINod(Nod Element); //Metoda abstracta //Va fi implementata in descendenti //Sterge un element de adresa specificata public abstract void stergeNodAdr(Nod Element);
//Metoda abstracta //Va fi implementata in descendenti //Insereaza un element de pozitie specificata public abstract void stergeNodNum(int Nr); //Metoda publica finala //Deocamdata neimplementata //Salveaza lista reperata de AStart //intr-un fisier de nume specificat public final void salveazaInFisier(char numef[]) { System.out.println("Neimplementata..."); }; //Metoda publica finala //Deocamdata neimplementata //Restaureaza lista folosind informatiile din fisierul //de nume specificat public final Nod incarcaDinFisier(char numef[]) { System.out.println("Neimplementata..."); return null; }; } Codul prezentat mai sus trebuie să fie extins şi rafinat pentru a acoperi toate cerinţele iniţiale ale problemei şi pentru a face faţă exigenţelor de calitate fireşti (modularizare corectă, fiabilitate, extensibilitate, etc.).
Capitolul 4 Specificarea şi implementarea unei clase Perspectiva C++ Comparaţie Java / C++
4.1 Specificarea şi implementarea unei clase în C++ 4.1.1 Scurtă introducere Istoria limbajului C a fost scrisă de mult. Istoria limbajului C++, încă se scrie. La apariţia lor, aceste două limbaje au făcut rapid prozeliţi mulţi, îndeosebi printre profesionişti. Noutăţile aduse la rampă de C şi C++ au fost preluate, fără drept de apel şi de către noile limbaje de programare (Java, C#), care au fost specificate în contextul apariţiei universului INTERNET. Atenţionând cititorul asupra faptului că în C++ orientarea pe obiecte are o mare diversitate de elemente suport, a căror prezentare în detaliu în această carte nu este posibilă din motive obiective, voi încerca, în continuare, prezentarea direcţiilor cheie pe care se poate merge în C++ pentru a face programare orientată pe obiecte.
4.1.2 Orientarea pe obiecte cu structurile şi uniunile din C++ Nevoia de sintaxă care să susţină implementarea orientată pe obiect a modelelor orientate pe obiecte a fost resimţită încă din epoca programării în C, epocă în care definirea structurilor îngăduia „punerea laolaltă a datelor şi a prelucrărilor”, ceea ce înseamnă suport de un anumit tip pentru încapsulare. Prezenţa prelucrărilor alături de date era realizată prin posibilitatea de a declara membri ai unei structuri care erau pointeri la funcţii. Contextul sintactic şi câte ceva despre semantica ideii de mai sus, în exemplele de mai jos. Cod C care prezintă un exemplu de structură orientată pe obiecte //Exemplul 4.1 #include<conio.h> #include<iostream.h> //Definirea tipului abstract de date //utilizand cuvantul cheie struct //cu semantica din C struct tad { int data;
//Exemplu de membru pointer la functie int (*funct)(); }; //Prototipul functiei, compatibila cu pointerul //specificat ca membru in struct int f(); //Instanta tad tad vtad; void main() { //Setare valoare membru data vtad.data=10; clrscr(); //Setare valoare membru pointer la functie vtad.funct=&f; //Utilizare membru pointer la functie cout<<vtad.funct(); getch(); }; //A se observa modul in care se realizeaza implementarea. //Accesarea membrilor de tip data se face in context //variabila struct int f() { return vtad.data* vtad.data; }; Cod C++ care prezintă un exemplu de structură orientată pe obiecte //Exemplul 4.2 #include<conio.h> #include<iostream.h> //Definirea tipului abstract de date //utilizand cuvantul cheie struct //cu semantica din C++ struct tad { int data;
//Exemplu de membru functie int funct(); }; int tad::funct() { return data*data; }; //Instanta tad tad vtad; void main() { //Setare valoare membru data vtad.data=10; clrscr(); //Utilizare membru functie //Se observa progresul fata de implementarea aceleasi idei //in C cout<<vtad.funct(); getch(); }; La vremea apariţiei lor aceste mecanisme au fost o surpriză foarte plăcută pentru programatorii educaţii în spiritul orientării pe obiecte. Evident, aceste mecanisme erau încă departe de semantica completă a orientării pe obiecte. Şi totuşi, cu ajutorul enunţului struct, programatorul poate defini structuri de date, reprezentabile în memoria internă, echivalente ale tipului înregistrare (record) teoretizat în algoritmică şi în numeroase limbaje de programare. Caracteristica fundamentală a acestor structuri o reprezintă posibilitatea de a grupa date membre care contribuie la caracterizarea informaţională a unui obiect, posibilitate care are ca rezultat şi introducerea unui mecanism de adresare nou (calificarea cu punct), cunoscut şi în Object Pascal, de exemplu. Evident, dacă vom considera necesar, pentru reprezentarea în memorie a unei structuri putem aloca memorie şi dinamic. Astfel apar pointerii la structuri de date, prilej cu care se combină forţa pointerilor cu posibilităţile pe care le oferă structurile. Cod C++ care prezintă un exemplu de structură orientată pe obiecte alocată dinamic
//Exemplul 4.3 #include<conio.h> #include<iostream.h> #include<alloc.h> //Definirea tipului abstract de date //utilizand cuvantuli cheie struct //cu semantica din C++ struct tad { int data; //Exemplu de membru functie int funct(); }; int tad::funct() { return data*data; }; //Declararea unui pointer la un tad tad *ptad; void main() { //Alocare de memorie pentru ptad ptad=(tad*)malloc(sizeof(tad)); //Utilizare pointer...observati mecanismul nou de selectare //a membrului in cazul pointerilor ptad->data=10; clrscr(); //Utilizare membru functie in context de pointer //la structura cout<<ptad->funct(); getch(); //Eliberarea memoriei gestionatÄ&#x192; de ptad free(ptad); };
Tipul union, prezent în C şi C++ este introdus, în esenţă, cu scopul de a permite partajarea de către datele membre a aceleeaşi zone de memorie, fapt care ne îngăduie să imaginăm foarte multe soluţii simple la situaţii frecvent întâlnite în realitate, a căror rezolvare, în lipsa tipului union, ne poate obliga la eforturi deosebite. Mai jos este prezentat un exemplu trivial de operare asupra conţinutului unui întreg, în condiţiile în care îl obligăm pe acesta să partajeze aceeaşi zonă de memorie cu un tablou de două caractere. Versatilitatea tabloului ne va permite să operăm asupra conţinutului întregului, dat fiind faptul că, atunci când fac parte dintr-o uniune, datele membre îşi partajează şi accesul la conţinutul comun. Cod C++ care prezintă un exemplu de uniune orientată pe obiecte //Exemplul 4.4 #include<conio.h> #include<iostream.h> //Definirea tipului abstract de date //utilizand cuvantul cheie union union tad { void schimb(); void set_octet(unsigned nr); void arata_cuvant(void); unsigned u; unsigned char c[2]; }; void tad::schimb() { unsigned char tamp; tamp=c[0]; c[0]=c[1]; c[1]=tamp; }; void tad::arata_cuvant() { cout<<u<<endl; }; void tad::set_octet(unsigned nr) { u=nr; };
void main() { clrscr(); tad vtad; vtad.set_octet(10000); cout<<"Inainte de..."; vtad.arata_cuvant(); vtad.schimb(); cout<<"Dupa...."; vtad.arata_cuvant(); getch(); }; Se mai pot spune multe lucruri despre puterea acestor două cuvinte cheie. De pildă, cuvântul cheie struct poate fi utilizat pentru a defini câmpuri de biţi, instrumente de lucru la nivel de structuri de biţi, extrem de puternice în C/C++. De asemenea, în cazul acestor structuri operează şi semantica specificatorilor de vizibilitate public şi private (asupra cărora vom reveni pe larg la prezentarea tipului class. După cum se vede, vizibilitatea implicită, atât în cazul struct cât şi în cazul union este publică. În sfărşit, ambele tipuri ne oferă posibilitatea de a face benefica distincţie între specificarea şi implementarea unei structuri de date. Mai mult decât aceste cuvinte cheie, obţinem dacă apelăm la tipul class, recunoscut de orice compilator de C++. De fapt, temelia specificării supersetului C++ o reprezintă tipul class şi extensiile cerute de acest tip pentru a spori suportul acordat programatorilor care lucrează orientat pe obiecte, în toate sensurile posibile.
4.1.3 Orientarea pe obiecte cu tipul class Adevărata fortă a orientării pe obiecte în C++ este întruchipată de tipul class, care aduce, pe lângă sintaxa de bază, o serie de alte elemente remarcabile pentru confortul şi forţa programării în C++ (clasele şi funcţiile prietene, suprascrierea operatorilor, moştenirea multiplă, clasele template, supradefinirea funcţiilor într-un lanţ de derivare, etc.). Voi încerca în continuare să prezint cât mai multe dintre aceste elemente, reunite în sintagma sintaxa de bază. Sintaxa C++ pentru specificarea unei clase class <Nume_clasă> [:<Nume_clasă_de_bază>] {
<Date membre> <Operaţii> [public: <Date membre> <Operaţii>] [protected: <Date membre> <Operaţii>] [private: <Date membre> <Operaţii>] }; După cum se poate observa, sintaxa C++ care stă la baza specificării unei clase conservă mare parte din semantica specificării unei clase în Java. În ceea ce priveşte sintaxa de bază pentru specificarea datelor membre şi a operaţiilor membre, cadrul C++ este prezentat mai jos. <Tip data> <Identificator>; <Tip returnat> <Nume operaţie>([<Lista de parametri>]); În ceea ce priveşte specificarea datelor membre, trebuie să atrag atenţia asupra faptului că nu se admite asocierea unor valori implicite la definirea datelor membre, problema setării valorice a datelor membre fiind exclusiv de competenţa programatorului (constructorii sau operaţiile dedicate setării valorilor datelor membre). În ceea ce priveşte specificarea operaţiilor membre, deocamdată să semnalăm faptul că, în principiu, există operaţii care respectă sintaxa precizată mai sus, dar există şi o excepţie importantă, constructorii, care nu acceptă să întoarcă un tip, nici măcar void. De asemenea, mai există o serie de variaţiuni la sintaxa de specificare a unei operaţii membre, precum: Orice versiune de constructor are acelaşi nume cu clasa; deosebirile între versiuni apar la lista de parametri. Destructorul are ca nume numele clasei prefixat cu caracterul ~. Funcţiile virtuale beneficiază de un supliment de sintaxă. Operaţiile statice, ca şi membrii statici, beneficiază, de asemenea, de un supliment de sintaxă. Utilizarea membrilor statici în C++ poate fi înţeleasă din Exemplul 4.5.
//Exemplul 4.5 #include <iostream.h> #include <conio.h> class Numar { int nr; //Data membra statica //Aici doar este definita //Cererea de memorie, catre compilator, este facuta //in zona globala a programului static float nf; public: int getnr(); //Operatie membra statica static float getnf(); Numar() { nr=0; nf=0.5; cout<<"Constructorul explicit...fara parametri"<<endl; }; Numar(int nr); }; //Cererea de memorie pentru data membra statica float Numar::nf; //Implementare externa constructor cu parametru void Numar::Numar(int nr) { Numar::nr=nr; nf=0.5; cout<<"A lucrat constructorul explicit...1 parametru"<<endl; }; //Implementare operatie membra statica //Nu se poate referi decat la date membre statice float Numar::getnf() { return nf; };
int Numar::getnr() { return nr; }; void main() { clrscr(); Numar N1=Numar(10); Numar N2; cout<<N1.getnr()<<endl; cout<<N2.getnr()<<endl; cout<<N2.getnf()<<endl; cout<<N1.getnf()<<endl; getch(); }; Din sintaxa cadru deducem că una dintre principalele probleme cu care ne confruntăm la specificarea unei clase în C++, la fel ca în Java, de altfel, este stabilirea condiţiilor de acces la membrii unei clase. Tabloul complet al semanticii condiţiilor de acces la membrii unei clase în C++ este prezentat în Figura 15.
class F class A
Membrii privaţi
friend class F;
Clasă prietenă a clasei A
Membrii protejaţi Funcţia f, prietenă a clasei A class B:public A
Figura 15. Controlul accesului în clasele C++ Aşadar, cuvintele cheie public, protected şi private ne ajută să definim secţiuni în care avem un anumit tip de vizibilitate. Fiecare astfel de secţiune începe cu unul din cuvintele cheie de mai sus, urmat de caracterul :. Valabilitatea unei astfel de secţiuni încetează la întâlnirea caracterului } sau la întâlnirea unui alt cuvânt cheie pentru controlul accesului. Un exemplu de secţiune publică avem în Exemplul 4.5. Pentru mai multă precizie să precizăm următoarele: Cuvântul cheie public introduce o secţiune ale cărei componente sunt vizibile în tot programul, într-un context adecvat. Cuvântul cheie protected introduce o secţiune ale cărei componente pot fi accesate doar de descendenţii clasei în care apare secţiunea. Cuvântul cheie private introduce o secţiune ale cărei componente sunt vizibile doar în interiorul clasei. Clienţii clasei pot accesa componentele private doar prin intermediul interfeţei.
Dacă definiţia clasei începe cu o secţiune în care nu apare nici unul din cuvintele cheie de mai sus, tipul de acces implicit este private. Sintaxa C++ pentru implementarea unei clase Codul aferent operaţiilor unei clase poate fi scris în afara clasei sau în interiorul acesteia. Implementarea externă a unei operaţii urmează sintaxa: <Tip><Nume clasă deţinătoare>::<Nume operaţie>([<Lista parametri>]) { //Cod }; De observat utilitatea operatorului de rezoluţie „::” la implementarea unei operaţii, pentru a indica compilatorului clasa deţinătoare a operaţiei. Utilizatorul de rezoluţie globală mai are şi alte valori de întrebuinţare în C++. Una dintre ele apare în Exemplul 4.5, unde operatorul :: este folosit pentru a transmite compilatorului o cerere de alocare de memorie pentru o variabilă statică. Dacă se optează pentru implementarea externă, atunci apelul unei funcţii, membră a clasei, se face în manierea cunoscută de la funcţii în genere (utilizarea stivei pentru a memora punctul de revenire şi datele locale ale funcţiei, predarea controlului către codul funcţiei iar la return golirea stivei, etc.). Toate aceste operaţii pot afecta performanţele programului în anumite condiţii. Evitarea unei astfel de situaţii se face implementând inline anumite funcţii membre. Implementând inline o funcţie îi spunem compilatorului ca fiecare apel al funcţiei inline să fie înlocuit cu o copie a codului funcţiei respective. Creşte viteza, dar poate să crească alarmant şi memoria internă folosită. De aceea, implementarea inline trebuie utilizată cu discernământ. O funcţie al cărei cod asociat este scris la definirea clasei este automat considerată ca inline de către compilator. O funcţie implementată în afara definiţiei clasei, a cărei signatură este prefixată de cuvântul cheie inline, este, de asemenea, tratată de compilator ca inline. inline <Tip> <Nume clasă deţinătoare>::<Nume operaţie> ([<Lista parametri>]) { //Cod };
Cu toate că este un truism pentru majoritatea cititorilor, să menţionăm faptul că o funcţie care întoarce un rezultat face acest lucru cu ajutorul instrucţiunii return, utilizată cu o sintaxă de tipul: return <Expresie>; Nu cred că este necesar să iniţiez o dezbatere specială pe tema controlului vizibilităţii asupra resurselor unei clase C++. Semantica tipurilor de vizibilitate este similară celei discutată pe larg în Cursul 3 (Specificarea unei clase în Java). Alocarea memoriei pentru obiecte în C++ Exemplele prezentate au arătat, deja, pentru cunoscători, cum se alocă memoria pentru obiecte în C++. În C++ putem aloca memorie pentru obiecte static sau dinamic. Varianta statică de alocare Revenind la Exemplul 4.5, observăm că avem două maniere de alocare statică a memoriei: : Numar N1=Numar(10); Numar N2; : Mai întâi, N1 este o instanţă a clasei Numar, creată şi iniţializată cu concursul constructorului parametrizat al clasei Numar. În al doilea rând, N2 este o instanţă a clasei Numar, creată şi iniţializată cu concursul constructorului fără parametri al clase Numar. Apelul constructorului fără parametri este implicit. Varianta dinamică de alocare Alocarea dinamică a memoriei pentru obiecte este înţeleasă corespunzător odată cu înţelegerea completă a rolului pointerilor în programarea C++. Dată fiind importanţa acestui subiect pentru formarea unui programator, voi insera o serie de consideraţii de strictă necesitate pentru înţelegerea şi utilizrarea pointerilor în C++. Modelul de memorie folosit pentru implementarea acestei idei este prezentat în Figura 16.
Variabilă pointer
Adresă de memorie
Memoria referită de adresă (porţiune contiguă)
Figura 16. Modelul de alocare dinamică a memorie în C++ Aşadar, tipul pointer este un tip de dată ale cărui instanţe pot păstra adrese către alte tipuri de date. Declararea unui pointer în C++ corespunde sintaxei de mai jos: <Tipul de bază al pointerului> *<Pointer>; Elementul magic în această declaraţie este caracterul „*” Cu ajutorul lui, în declaraţia de mai sus am specificat compilatorului că <Pointer> este o variabilă care poate păstra o adresă către o porţiune de memorie în care am una sau mai multe valori, al căror tip definitor este <Tipul de bază al pointerului>. În variabila <Pointer> putem obţine o adresă în mai multe moduri: Prin atribuire, de la altă variabilă pointer; Prin atribuirea adresei unei variabile statice (folosind operatorul &, de obţinere a adresei unei variabile statice; de menţionat faptul că operatorul & se mai foloseşte şi pentru definirea de alias-uri sau în sintaxa de transmitere prin referinţă a parametrilor unei funcţii); Prin formularea unei cereri de alocare de memorie (în unul dintre modurile posibile în C şi C++), caz în care, dacă cererea poate fi rezolvată se va primi adresa unei porţiuni din Heap, memorie destinată special alocării dinamice. Această ultimă consideraţie este dependentă de platforma DOS, ca platformă ţintă pentru executabilul rezultat în urma compilării. În Windows, datele problemei în ceea ce priveşte alocarea dinamică a memorie se modifică, dat fiind faptul că avem alt model de memorie. Nu mai lucrăm cu memorie segmentată ci cu memorie flat, manevrabilă după alte tehnici, mult mai bine puse la punct decât cele corespunzătoare platformei DOS.
În momentul în care în variabila pointer avem o adresă, începe să se manifeste potenţialul pointerilor sub forma: aritmetica pointerilor, paralela tablou-pointer, conversii, creare de structuri dinamice, lucrul la nivel de octeţi şi biţi, etc. Revenind la Exemplul 4.5, ne putem imagina că am declarat un pointer la clasa Numar (fie acesta pnumar) pe care l-am asociat cu o adresă prin apelul operatorului new, urmat de apelul constructorului, pentru a realiza iniţializarea. A se urmări codul prezentat mai jos. : //Declarare şi alocare de memorie Numar *pnumar=new Numar(11); //Utilizare pointer //Referirea unui membru in contextul unui pointer //se face cu ajutorul secventei de simboluri „->”. cout<<pnumar->getnr()<<endl; cout<<pnumar->getnf()<<endl; : Problematica metodelor virtuale sau a metodelor virtuale pure o vom relua în contextul discuţiei referitaore la polimorfism. Implicaţiile moştenirii şi polimorfismului asupra unora dintre conceptele prezentate mai sus, vor fi discutate în capitolele următoare. Se poate urmări, în continuare, varianta C++ a codului Java pentru schiţarea, la nivel înalt de abstractizare, a soluţiei pentru problema LSI.
Cod C++ care exemplifica utilizarea tipului class pentru rezolvarea problemei LSI #include <iostream.h> #include <conio.h> //Specificare minimala a clasei Nod class Nod { int Data; Nod *Urmatorul; public: //constructorul explicit al clasei Nod
Nod(int Numar); //modificator atribut Data void setareData(int Numar); //selector atribut Data int consultareData(); //Modificator atribut Urmatorul void setUrmator(Nod *Element); //Selector atribut Urmatorul Nod *consultaUrmator(); }; //Specificare clasa abstrcata Lista class Lista { Nod *AStart; Nod *AUltim; int NrElem; public: //Metoda virtuala pura virtual void insereazaDNod(Nod Element)=0; //Metoda virtuala pura virtual void insereazaINod(Nod Element)=0; //Metoda virtuala pura virtual void stergeNodAdr(Nod Element)=0; //Metoda virtuala pura virtual void stergeNodNum(int Nr)=0; //Modificator atribut AStart implementat inline inline void setareAStart(Nod *Element) { AStart=Element; };
//Selector atribut AStart implementat inline inline Nod *consultaAStart() { return AStart; }; //Modificator atribut NrElem implementat inline inline void setareNrElem(int ne) { NrElem=ne; }; //Selector atribut NrElem implementat inline inline int consultaNrElem() { return NrElem; }; //Modificator atribut AUltim implementat inline inline void setareAUltim(Nod *Element) { AUltim=Element; }; //Selector atribut AUltim implementat inline inline Nod *consultaAUltim() { return AUltim; }; //Metoda publica //Salvare lista in fisier de nume specificat void salveazaInFisier(char numef[]); //Metoda publica //Restaureaza lista folosind informatiile din fisierul //de nume specificat Nod *incarcaDinFisier(char numef[]); }; class ListaOarecare: public Lista
{ public: //Constructor explicit inline inline ListaOarecare() { setareAStart(NULL); setareAUltim(NULL); setareNrElem(0); cout<<"Constructor ListaOarecare..."<<endl; }; // inline void insereazaDNod(Nod Element) { cout<<"Neimplementata...Lista oarecare"<<endl; setareNrElem(consultaNrElem()+1); } inline void insereazaINod(Nod Element) { cout<<"Neimplementata...Lista oarecare"<<endl; setareNrElem(consultaNrElem()+1); }; inline void stergeNodAdr(Nod Element) { cout<<"Neimplementata...Lista oarecare"<<endl; }; inline void stergeNodNum(int Nr) { cout<<"Neimplementata...Lista oarecare"<<endl; }; }; //Implementare Nod::Nod(int Numar) { Data=Numar; }; //modificator atribut Data
void Nod::setareData(int Numar) { Data=Numar; }; //selector atribut Data int Nod::consultareData() { return Data; }; //Modificator atribut Urmatorul void Nod::setUrmator(Nod *Element) { Urmatorul=Element; }; //Selector atribut Urmatorul Nod *Nod::consultaUrmator() { return Urmatorul; }; //Specificare aproape completa a clasei abstracte Lista void Lista::salveazaInFisier(char numef[]) { cout<<"Neimplementata...Lista"<<endl; }; //Metoda publica //Deocamdata neimplementata //Restaureaza lista folosind informatiile din fisierul //de nume specificat Nod *Lista::incarcaDinFisier(char numef[]) { cout<<"Neimplementata...Lista"<<endl; return NULL; }; //Functia principala //Introdusa doar ca parghie pentru verificarea
//evolutiei implementarii codului //In stadiul final codul va fi utilizat ca un ansablu //de doua fisiere antet: unul care contine //specificarea claselor si unul care realizeaza implementarea // acestor clase void main() { clrscr(); //Alocarea dinamica a memoriei pentru obiecte //in C++ ListaOarecare *LO=new ListaOarecare(); LO->salveazaInFisier("Test"); LO->stergeNodNum(1); getch(); }; Invit cititorul să depună eforturi personale pentru a aprofunda următoarele subiecte: Rolul pointerilor în programarea C++. Legătura cu tablourile. Pointerii la funcţii. Clase şi funcţii prietene. Constructorii şi destructorii în programarea C++.
4.2 Comparaţie Java/C++ Similitudinile sintactice şi semantice care apar în utilizarea limbajelor Java şi C++ sunt de necontestat. Similitudinile sunt benefice pentru fanii C++ care migrează către Java sau invers. În categoria similitudinilor putem include: Acelaşi nucleu de cuvinte cheie utilizat în cele două limbaje (class, public, protected, private, static); Aceleaşi principii promovate(încapsularea, moştenirea, polimorfismul); Sintaxă asemănătoare pentru reprezentarea prelucrărilor aferente operaţiilor;
La capitolul deosebiri semnalez: Modul diferit de utilizare a cuvintelor cheie pentru controlul accesului la resursele unei clase; Modul diferit de rezolvare, în cele două limbaje, a problemei alocării dinamice a memoriei pentru obiecte; Diferenţa de abordare în ceea ce priveşte moştenirea (simplă, în Java, multiplă, în C++). Fiecare soluţie are fani şi critici. Diferenţe în rezolvarea problemei supradefinirii în cele două limbaje (metode virtuale, în C++, pur şi simplu supradefinire pentru metode cu acelaşi nume, în clase înrudite, în Java). Soluţia Java pare mai elegantă. Diferenţe datorate prezenţei pointerilor în C++ şi absenţei lor din Java. Diferenţe datorate prezenţei claselor şi funcţiior prietene în C++ şi absenţei lor din Java. Diferenţe datorate lipsei claselor template din Java. Diferenţe datorate posibilităţii de a supraîncărca operatorii în C++ şi lipsei de suport în acest sens în Java. Diferenţe în ceea ce priveşte controlul complexităţii şi modularizării proiectelor în cele două limbaje (pachete în Java, fişiere header în C++). Diferenţe în rezolvarea problemei implementării operaţiilor unei clase. Flexibilitatea şi uşurinţa în manevrarea datelor membre statice (Java stă mai bine). Posibilitatea de a bloca supradefinirea unei operaţii într-un lanţ de derivare (Java oferă suport, C++ nu). Facilităţi pentru crearea de clase singleton (Java are suport, C++ nu) etc. Pentru curioşi, apariţia limbajului C#, însoţit de o logistică impresionantă, ar putea să însemne rezolvarea multora dintre aceste diferenţe.
Capitolul 5 Moştenirea în programarea orientată pe obiecte Perspectiva Java şi Perspectiva C++
5.1 Scurtă introducere Am văzut, deja, faptul că unul dintre principiile importante în programarea orientată pe obiecte este principiul moştenirii. Aşa cum se întâmplă, în general, cu principiile, nici principiul moştenirii nu trebuie fetişizat sau bagatelizat. Bagatelizarea lui înseamnă, ceva de genul: „Hai să facem moştenire ca să ne aflăm în treabă sau ca să vedem dacă funcţionează”. Fetişizarea, din contră, ar însemna „Sfinte Sisoe, nimic mai frumos şi mai eficient decât aplicarea principiului moştenirii la tot pasul”.Ambele variante sunt false. Cum am mai spus şi altă dată, principiul moştenirii este disponibil pentru uz, nu pentru abuz. Ceva mai concret spus:
Moştenirea este o modalitate performantă de reutilizare a codului, dar nu este întotdeauna cel mai bun instrument pentru îndeplinirea acestui obiectiv. Dacă este folosită necorespunzător, programele obţinute vor fi destul de fragile. Moştenirea poate fi utilizată cu succes în cadrul unui pachet, unde implementările subclaselor şi superclaselor sunt controlate de aceeaşi programatori. De asemenea, poate fi folosită la extinderea claselor care au fost concepute şi documentate exact în acest scop. Însă, moştenirea unor clase concrete obişnuite, în afara graniţelor unui pachet, poate fi periculoasă. Trebuie să subliniez faptul că toate consideraţiile pe care le fac în acest paragraf se referă la moştenirea înţeleasă ca moştenire a implementării (o clasă extinde o altă clasă, exprimându-ne în spirit Java). Se ştie, desigur că relaţia de moştenire operează şi în relaţia dintre interfeţe, chestiune care nu cade sub incidenţa observaţiilor critice, de mai sus, la adresa moştenirii. Defecţiunea esenţială care poate apare când apelăm la moştenire se referă la faptul că, prin moştenire, încapsularea are de suferit în mod natural. Şi aceasta deoarece funcţionarea corectă a unei subclase depinde de detaliile de implementare ale superclasei. Implementarea superclasei se poate modifica de la o versiune a programului la alta, situaţie în care , subclasa este expusă deteriorării chiar şi în cazul în care codul ei a rămas neatins. Prin urmare, subclasa este obligată să evolueze odată cu superclasa, exceptând, bineînţeles, situaţia în care autorii superclasei au conceput-o şi documentat-o special pentru a fi extinsă. O regulă de bun simţ în utilizarea principiului moştenirii ne spune că este bine să folosim moştenirea numai dacă subclasa este, într-adevăr, un subtip al superclasei. Altfel spus, o clasă B trebuie să extindă o clasă A numai dacă între cele două clase există o relaţie de tipul „B este un A”. Cu referire la mulţimea poligoanelor, părerea unor aşa zişi specialişti, conform căreia putem deriva clasa dreptunghi din clasa triunghi este complet în afara logicii relaţiei de moştenire. Nici în visul cel mai frumos un
dreptungi nu este un triunghi. În schimb, putem spune că triunghiul este un descendent al poligonului, pe motiv că orice triunghi este un poligon, etc. Concluzionând, moştenirea este un aspect important în programare, dar problematic, pentru că poate fi aplicat denaturându-i esenţa şi pentru că încalcă, în mod natural, regulile încapsulării. Moştenirea se poate folosi eficient numai când între superclasă şi subclasă există o relaţie reală de genul „tip-subtip”. Chiar şi în acest caz, codul obţinut poate fi fragil, având probleme când apar modificări şi fiind predispus la anumite breşe în securitatea resurselor unei clase. Evitarea unor astfel de probleme este posibilă, în anumite situaţii, prin utilizarea compunerii în locul moştenirii. Relaţia de compunere presupune ca în definiţia unei clase A să apară şi instanţe ale altor clase, ale căror servicii urmează să fie utilizate de către clasa A. Pe de altă parte, este tot atât de adevărat faptul că multe probleme rezolvate în C++ cu ajutorul pointerilor, nu ar avea rezolvare echivalentă semantic în Java, fără suportul moştenirii. 5.2 Moştenirea în Java Nu este cazul să revin asupra sintaxei operaţiei de moştenire în Java. Java a optat pentru un cuvânt cheie pentru a informa compilatorul de intenţia unei clase de a moşteni proprietăţile altei clase. Acest cuvânt cheie este extends. Ceea ce trebuie să semnalăm ca important, pentru spiritul moştenirii în limbajul Java, este faptul că acesta nu oferă suport pentru moştenirea multiplă, în sensul în care este aceasta înţeleasă în alte limbaje. În Java, o clasă poate avea o singură superclasă. De aici rezultă posibilitatea teoretică de a construi doar ierarhii de clase, cu suportul oferit de moştenirea cu extends. Dat fiind faptul că în practică există şi nenumărate situaţii în care semantica moştenirii multiple se impune ca alternativa cea mai valabilă, Java oferă o portiţă pentru a simula moştenirea multiplă cu ajutorul interfeţelor. Jongleriile care se pot face cu ajutorul interfeţelor au depăşit de mult intenţiile iniţiale ale specificatorilor limbajului Java. Mă voi ocupa de acest subiect în paragraful 5.3. Exemplul 5.1 ilustrează moştenirea, având ca idee călăuzitoare specializarea clasei de bază prin adăugare de membri noi. De asemenea, Exemplul 5.1 ilustrează şi cele două tipuri de casting posibile în programarea orientată pe obiecte din Java. În esenţă, este vorba despre faptul că, având contextul din Figura 17, sunt posibile două tipuri de casting, principial deosebite.
A
B Figura 17. Derivare pretext pentru două tipuri de casting Primul tip de casting este implicit. Este vorba despre conversia unei variabile obiect de tip B la o variabilă de tip A. Motivul pentru care acest tip de conversie este implicit este simplu: resursele lui A se regăsesc printre resursele lui B. Astfel că, atunci când B este coerent din punct de vedere al stării, nu există nici un motiv ca A să fie altfel, în urma conversiei. Al doilea tip de casting necesită acordul explicit al programatorului pentru a fi efectuat şi într-un anumit sens, asumarea răspunderii pentru această conversie. Este vorba de conversia unei variabile obiect de tip A la o variabilă de tip B. De ce este necesar acordul? Foarte simplu: resursele lui A sunt incluse printre resursele lui B. Conversia de mai sus este posibil să aducă variabila de tip B într-o stare improprie pentru utilizarea unor metode, deoarece unele date membre pot rămâne neiniţializate adecvat. Cu toate acestea, down casting-ul este esenţial în programarea generică, asupra căreia vom reveni într-un curs special. Exemplul 5.1 //Clasa de bază //Modelează informaţional obiectul Fruct //Potrivit abordării din acest cod //metoda consultaTipFruct() nu poate fi //redefinită în descendenţi class Fruct { private int tipfruct; Fruct(int t) { tipfruct=t; System.out.println("Constructor Fruct..."); } final int consultaTipFruct() {
return tipfruct; } } //Subclasă a clasei Fruct //Para este un fruct->este respectat spiritul natural //al moştenirii //Se adaugă noi atribute informaţionale //Se adaugă metode specifice //Este un exemplu clasic de specializare prin adaugare class Para extends Fruct { private double greutate; private int forma; Para(int t,double g,int f) { super(t); System.out.println("Constructor Para..."); greutate=g; forma=f; } double consultaGreutate() { return greutate; } int consultaForma() { return forma; } } //Subclasă a clasei Fruct //Portocala este un fruct->este respectat spiritul natural //al moştenirii //Se adaugă noi atribute informaţionale //Se adaugă metode specifice //Este un exemplu clasic de specializare prin adăugare class Portocala extends Fruct { private int tipcoaja; Portocala(int tf,int tc) {
super(tf); System.out.println("Constructor Para..."); tipcoaja=tc; } int consultaTipCoaja() { return tipcoaja; } } //Clasa care utilizează ierarhia de clase de mai sus //Tipurile Para şi Portocala au acelaşi supertip //dar sunt incompatibile la atribuire //deoarece sunt părţi ale unor lanţuri de derivare //diferite public class Mosten1 { public static void main(String[] s) { //Declarare variabilă referinţă //având tipul clasei rădăcină Fruct of; //Declarare şi alocare variabilă referinţă //de tip Para Para obpara=new Para(1,1.5,2); //Declarare şi alocare variabilă referinţă //de tip Portocala Portocala obport=new Portocala(10,1); //Utilizare normală a variabilei de tip Para System.out.println("Para ..creata ca referinta Para"); System.out.println("Tip fruct:"+ obpara.consultaTipFruct()); System.out.println("Greutate fruct:"+obpara.consultaGreutate()); System.out.println("Forma fruct:"+obpara.consultaForma()); //Utilizare normala a variabilei de tip Portocala System.out.println("Portocala creata ca referinta Portocala"); System.out.println("Tip fruct:"+obport.consultaTipFruct()); System.out.println("Tip coaja:"+obport.consultaTipCoaja());
//Exemplu de Up casting (implicit) of=new Para(1,2.5,3); //Exemplu de Down casting (explicit); //Foarte util in programarea generica obpara=(Para)of; //Utilizare variabile referinţă //setate prin casting explicit System.out.println("Para ...creata ca referinta Fruct"); System.out.println("Tip fruct:"+obpara.consultaTipFruct()); System.out.println("Greutate fruct:" +obpara.consultaGreutate()); System.out.println("Forma fruct:"+obpara.consultaForma()); } }; Exemplul 5.2 ilustrează specializarea prin redefinirea metodelor, ca bază pentru comportamentul polimorfic al obiectelor în Java. Mecanismul polimorfismului îl voi pune în discuţie în Capitolul 6. Exemplul 5.2 //Clasa de baza //Modelează informţional obiectul Fruct //Metoda consultaTipFruct() se va redefini //in descendentul Para class Fruct { private int tipfruct; Fruct(int t) { tipfruct=t; System.out.println("Constructor Fruct..."); } int consultaTipFruct() { return tipfruct; } } //Subclasă a clasei Fruct //Para este un fruct->este respectat spiritul natural
//al moştenirii //Se adaugă noi atribute informaţionale //Se redefineşte metoda consultaTipFruct() //Este un exemplu clasic de specializare prin redefinire class Para extends Fruct { private double greutate; private int forma; Para(int t,double g,int f) { super(t); System.out.println("Constructor Para..."); greutate=g; forma=f; } int consultaTipFruct() { System.out.println("Consultare tip fruct..redefinire Para"); return super.consultaTipFruct(); } double consultaGreutate() { return greutate; } int consultaForma() { return forma; } } //Subclasă a clasei Fruct //Portocala este un fruct->este respectat spiritul natural //al moştenirii //Se adaugă noi atribute informaţionale //Se redefineşte metoda consultaTipFruct() //Este un exemplu clasic de specializare prin redefinire class Portocala extends Fruct { private double greutate; private int forma;
Portocala(int t,double g,int f) { super(t); System.out.println("Constructor Portocala..."); greutate=g; forma=f; } int consultaTipFruct() { System.out.println("Consultare tip fruct..redefinire Porto"); return super.consultaTipFruct(); } double consultaGreutate() { return greutate; } int consultaForma() { return forma; } } public class Mosten2 { public static void main(String[] s) { //Declarare variabilă referinţă de tipul clasei rădăcină Fruct of; //Alocare referinţă utilizând constructorul unei subclase //a clasei rădăcină (Para) of=new Para(1,2.75,3); System.out.println("Para ...creata ca referinta Fruct"); //Utilizare, de fapt, în spirit polimorfic //a variabilei definite şi alocate mai sus System.out.println("Tip fruct:"+of.consultaTipFruct()); //Alocare referinţă utilizând constructorul unei subclase //a clasei rădăcină (Portocala) of=new Portocala(2,0.75,4);
System.out.println("Portocala ...creata ca referinta Fruct"); //Utilizare, de fapt, în spirit polimorfic //a variabilei definite si alocate mai sus System.out.println("Tip fruct:"+of.consultaTipFruct()); } }; 5.3 Moştenirea multiplă în Java Deşi o modalitate comodă de a ieşi din impas în anumite situaţii, moştenirea multiplă este criticată pentru confuziile pe care le poate genera, dacă este utilizată fără un „efort de inventariere”, adecvat focalizat asupra proprietăţilor claselor candidate la calitatea de superclase pentru o clasă derivată din ele. Duplicarea câmpurilor şi a metodelor, precum şi violarea crasă a încapsulării sunt principalele motive de îngrijorare când este vorba de utilizarea moştenirii multiple. Aşadar, ne aflăm în situaţia din Figura 18. A
B
C Figura 18. Moştenirea multiplă Dacă semantica din domeniul problemei impune o asemenea abordare, din punct de vedere conceptual, atunci în Java soluţia pentru posibilitatea de a spune despre C că este A sau B sau A şi B o reprezintă utilizarea interfeţelor. Interfaţa este o varietate de clasă, caracterizată prin faptul că poate declara metode abstracte şi publice şi, dacă este necesar, variabile care sunt considerate, implicit, de tip public, static şi final, deci constante. În foarte mare măsură, comportamentul unei interfeţe este asemănător comportamentului unei clase. Atât de profundă este asemănarea încât, în anumite situaţii interfeţele şi clasele se pot substitui reciproc. Pentru a înţelege modul de lucru cu interfeţele consider esenţiale următoarele precizări: 1. Mai întâi trebuie să învăţăm cum se declară o interfaţă. Aceasta este o problemă de sintaxă, mult mai simplă decât problema conceptuală, care
trebuie clarificată înainte de a ajunge la sintaxă. Din punct de vedere conceptual, tipul de raţionament pe care îl facem seamănă cu cel pe care îl facem când specificăm o clasă abstractă care are toate metodele virtuale pure, în C++ sau abstracte în Java. Din punct de vedere sintactic, avem cadrul: interface <Nume_interfaţă> [extends <Listă _de_interfeţe>] { <Signaturi de metode> } Se observă, deja, amănuntul care deosebeşte „mecanica utilizării claselor” de „mecanica utilizării interfeţelor”, anume, suportul pentru moştenire multiplă în cazul interfeţelor. Pe acest amănunt se bazează alternativa Java la moştenirea multiplă relativ la clase. 2. În al doilea rând, utilizarea unei interfeţe este posibilă, în mai multe moduri, după ce am specificat şi implementat o clasă care o utilizează. Ne amintim de sintaxa: class <Nume_clasă> [extends Nume_superclasă] implements <Listă de interfeţe> { <Date membre> <Funcţii membre> }; După cum se poate observa, o clasă poate implementa mai multe interfeţe, depăşindu-se, astfel, restricţia Java în legătură cu moştenirea multiplă în relaţia dintre clase. Să mai subliniez şi faptul că o clasă care implementează una sau mai multe interfeţe poate avea cel mult un strămoş, eventual nici unul. Disciplina astfel introdusă este, sper, clară: metodele unei clase (care are, eventual un strămoş) pot fi acoperitoare ca specificare şi implementare pentru listele de metode ale unor de interfeţe. În acest mod, avem la dispoziţie un mecanism de a vedea, din unghiuri diferite, ansamblul resurselor unei clase. Este clar că soluţia Java determină programatorii să mediteze mai atent înainte de a face joncţiunea cu mai multe interfeţe. 3. Utilizarea efectivă a interfeţelor este diversă: ca tipuri definitoare pentru referinţe la obiecte, ca tipuri implicate în casting, ca tipuri utile în programarea generică, etc. Căteva modalităţi de utilizare a interfeţelor se pot vedea în Exemplul 5.3 şi în Exemplul 5.4.
Exemplul 5.3 //Interfata I1 //Expune functia f1() interface I1 { public void f1(); } //Interfata I2 //Expune functia f2() interface I2 { public void f2(); }; //Interfata I12 //Extinde interfetele I1 si I2 //Exemplu de mostenire multipla //a interfetelor interface I12 extends I1,I2 { public void f3(); } //Clasa A implementeaza interfata I1 class A implements I1 { public A() { System.out.println("AAAA..."); }; public void f1() { System.out.println("f1...."); }; }; //Clasa B implementeaza interfata I2 class B implements I2 { public B() {
System.out.println("BBBB..."); }; public void f2() { System.out.println("f2...."); }; }; //Clasa C implementeaza interftata I12 //Evident,functiile expuse de interfetele //I1 si I2 sunt implementate si de catre //clasa C //Deoarece I12 este derivata din I1 si I2 //ni se ingaduie sa privim instantele de tip //I12 ca fiind de tip I1 sau I2, dupa cum //este in interesul nostru class C implements I12 { public C() { System.out.println("CCCC..."); }; public void f3() { System.out.println("f3..."); }; public void f1() { System.out.println("f1...."); }; public void f2() { System.out.println("f2...."); }; };
public class TestInterf { public static void main(String args[]) { //Crearea unui obiect de tip I1
//utilizand clasa A I1 ob1=new A(); System.out.println("Ura..."); //Utilizarea obiectului de tip I1 ob1.f1(); //Crearea unui obiect de tip I12 //utilizand clasa C I12 ob12=new C(); //Utilizarea obiectului de tip I12 ob12.f1(); ob12.f2(); ob12.f3(); //Crearea unui obiect de tip I1 //utilizand clasa C I1 ob2=new C(); //Utilizare obiect de tip I1 //creat cu suport C ob2.f1(); //Down casting dirijat de //interfete ob2=(I1)ob12; ob2.f1(); } }
Exemplul 5.4 //Interfata IConst care declara //doua constante si o metoda interface IConst { int Numar=100; String s="Bine ati venit!"; void ftest(); } //A foloseste interfata IConst
//Este obligata sa implementeze ftest() class A implements IConst { A() { System.out.println("Constructor.."); }; public void ftest() { System.out.println("ftest() la lucru..."); }; } public class TInterf1 { public static void main(String s[]) { //Creare variabila obiect //de tip A //se mostenesc constantele //interfetei IConst A va=new A(); System.out.println(va.Numar); System.out.println(va.s); va.ftest(); //Creare variabila obiect //de tip IConst IConst iva=new A(); System.out.println(iva.Numar); System.out.println(iva.s); iva.ftest(); }; } Înainte de a pune punct subiectului să mai precizez următoarele: Un program nu poate crea instanţe dintr-o interfată. Toate metodele unei interfeţe sunt implicit publice şi abstracte. Nici un alt tip nu este permis. Toate metodele trebuie să fie implementate de clasa care utilizează interfaţa.
5.4 Moştenirea în C++ C++ propune caracterul „:” ca separator între clasa de bază şi superclasă pentru a indica compilatorului necesitatea asigurării logisticii specifice unei operaţii de moştenire. Sintaxa efectivă a moştenirii ne oferă chiar mai mult decât atât, după cum se poate vedea mai jos. class <Nume_clasa>[[:][<Modificator de protecţie>] <Lista de superclase>] { <Date membre> <Funcţii membre> }; Din sintaxa de mai sus, se poate deduce faptul că o clasă A poate moşteni o clasă B într-un mod mai flexibil decât în Java. Modificatorul de protecţie, care poate apare, opţional, între operatorul de derivare „:” şi superclasă introduce o serie de combinaţii între vizibilitatea resurselor în clasa moştenită şi modificatorul de acces asociat operaţiei de moştenire, combinaţii care pot fi acoperitoare pentru cele mai neaşteptate semantici întâlnite în diverse probleme, după cum rezultă şi din Tabelul 5.1. Tipul de acces al Modificatorul de Accesul în clasa elementului în protecţie asociat clasei de derivată la element clasa de bază bază la definirea clasei Private Private interzis Protected Private private Public Private private Private Public interzis Protected Public protected Public Public public Tabelul 5.1 Problematica accesului la membrii unei clase derivate Un exemplu interesant este răspunsul C++ la următoarea problemă: Dată clasa A, ce sintaxă de derivare folosim pentru ca o clasă B să moştenească doar implementarea interfeţei clasei A? Mai exact spus, B să moştenească implementarea interfeţei clasei A dar să nu o poată exporta direct către nici un tip de client. Evident, în practică, există numeroase astfel de situaţii în care dorim să folosim interfeţele unor clase fără a le expune direct clienţilor.
Soluţia tehnică a acestei probleme este moştenirea privată a clasei A de către clasa B (varianta a 3-a din Tabelul 5.1), după cum se poate observa şi în Exemplul 5.5. Exemplul 5.5 #include <iostream.h> #include <conio.h> //Superclasa căreia vrem să-i moştenim //doar implementarea interfeţei //adică: // constructorul Baza() // metoda setarenr() // metoda citestenr() class Baza { int nr; public: Baza() { nr=0; }; void setarenr(int n) { nr=n; }; int citestenr() { return nr; }; }; //Clasa derivată Urmas care //moşteneşte privat superclasa Baza class Urmas:private Baza { public: Urmas() { }; void setaren(int n)
{ setarenr(n); }; int afisarenr() { return transform(); }; int transform() { return citestenr()*citestenr(); }; }; void main() { Urmas ourm; clrscr(); ourm.setaren(100); cout<<ourm.transform(); ourm.afisarenr(); //Ar fi ilegal enunţul ourm.citestenr()în //acest program deoarece ar însemna să expun clienţilor //însăşi interfaţa clasei Baza //ceea ce contrazice intenţia noastră getch(); }; Conform uzanţelor din C++, moştenirea implicită a unei clase de către altă clasă este moştenirea privată. Prin urmare, cuvântul cheie private în derivarea din Exemplul 5.5 este redundant, fiind pus în scop didactic. După cum anunţă şi sintaxa prezentată la începutul paragrafului 5.4, în C++ se poate face moştenire multiplă. Extrem de avantajos, cu o condiţie: cel ce face moştenire multiplă să o planifice temeinic înainte de a o folosi efectiv. Discuţiile sunt oricum prea vaste pe această temă. Pentru un programator la început de drum în programarea orientată pe obiecte, moştenirea multiplă trebuie să fie o soluţie extremă, la care se apelează după foarte multe experimente şi cercetări asupra unor şabloane în materie de moştenire multiplă. Deranjul provocat de moştenirea multiplă nu este numai la utilizare ci, mai ales, în procesul de programare. Practic, fiecare clasă poate fi considerată ca având un API propriu. Combinarea a două sau a mai multor clase într-o clasă nouă, prin moştenire, înseamnă combinarea API-urilor. În cazul proiectelor mari, este uşor de prevăzut ce se poate întâmpla dacă nu asigurăm API-uri dizjuncte din punct de
vedere al serviciilor expuse sau, în cel mai fericit caz, care să conducă la suprascriere în clasa colectoare. Alta este situaţia în C++ şi în ceea ce priveşte transmiterea de parametri către constructorii superclasei. Ceea ce în Java era rezolvat prin apelarea prioritară a constructorului superclasei, referit ca super, în C++ trebuie să respecte sintaxa: <Clasa derivata> ([<Lista parametri>]): <Superclasa 1>(<Lista de argumente 1>), <Superclasa 2>(<Lista de argumente 2>), <Superclasa 3>(<Lista de argumente 3>), : <Superclasa N>(<Lista de argumente N>) { //Corpul constructorului } Acestea sunt deosebirile de abordare între C++ şi Java în problema moştenirii. Cum beneficiem de moştenire? Aceasta este o întrebare cu un răspuns vast, niciodată lămurit până la capăt. Evident, vom avea compatibilitate la atribuire între strămoşi şi descendenţii lor (vezi principiul substituţiei!), deci vom putea beneficia de casting implicit. Cu riscurile de rigoare se îngăduie şi casting-ul explicit. În încheiere, să ne imaginăm că am implementat toate metodele claselor care constituie soluţia problemei LSI. Presupunând că am declarat un pointer la clasa Lista, atunci, compatibilitatea acestui pointer cu oricare pointer la descendenţii ListaOarecare şi ListaOrdonata va permite schimbarea fără probleme a modului de operare asupra unei liste, în anumite circumstanţe, desigur. O instanţă de tip ListaOrdonata trebuie să aibă un switch care abstractizează instaurarea sau nu a relaţiei de ordine între elementele unei liste. În caz că nu s-a instaurat această ordine, se activează automat refacerea stării de ordine, ori de câte ori este cazul. Evident, problema apare numai atunci când facem conversii de la oarecare la ordonat. Invers nu este nici o dramă.
Capitolul 6 Polimorfismul în programarea orientată pe obiecte Perspectiva Java şi perspectiva C++
6.1 Să reamintim, pe scurt, ce este polimorfismul. După cum stau lucrurile în limbajele de programare orientate pe obiecte, polimorfismul este singurul principiu a cărui forţă se manifestă în timpul execuţiei programelor. Valoarea principiului moştenirii este esenţial concentrată în posibilitatea de a reutiliza efortul de dezvoltare a unui sistem soft. Încapsularea este, de asemenea, un principiu a cărui manifestare nu este evidentă decât de pe poziţia de programator, în esenţă. Ar fi, însă, nedrept să nu subliniem că atât încapsularea cât şi moştenirea trebuie să fie mânuite
cu multă abilitate pentru a obţine efecte polimorfice de mare subtilitate şi utilitate. Încercând o definiţie a polimorfismului, independentă de limbajul de programare şi din punctul de vedere al programatorului care beneficiază de el, numim polimorfism posibilitatea ca un apel de funcţie (metodă , operaţie) să genereze răspunsuri diferite în funcţie de contextul în care a fost formulat.
6.2 Tipuri de polimorfism la nivelul limbajelor de programare. Exemplificare în C/C++ Nevoia de polimorfism, resimţită acut mai ales în programare, este în mare măsură sinonimă cu nevoia de confort. În stadiul în care se află, actualmente, realizările specialiştilor în materie de polimorfism la nivelul limbajelor de programare, putem semnala următoarele tipuri importante de polimorfism: Polimorfismul orientat pe suprascrierea funcţiilor în programarea clasică. Polimorfismul orientat pe suprascrierea funcţiilor în cadrul definiţiei unei clase. Polimorfsimul orientat pe supraîncărcarea operatorilor în programarea orientată pe obiecte. Polimorfismul orientat pe redefinirea funcţiilor în programarea orientată pe obiecte, într-un lanţ de derivare. Indiferent de tipul lui, polimorfismul de calitate cere investiţie de timp şi creativitate, pe moment, în beneficiul unor viitoare reutilizări, cu minimum de efort din partea clienţilor. Polimorfismul orientat pe suprascrierea funcţiilor în programarea clasică Această formă de polimorfism este, practic, cea mai veche. Ea presupune posibilitatea de a scrie funcţii care au acelaşi nume, retunează acelaşi tip de dată, dar se pot deosebi prin tipul şi numărul parametrilor. Această posibilitate este ilustrată în Exemplul 6.1, perfect legal în programarea în limbajele C/C++. Exemplul 6.1 //Suprascrierea funcţiilor în programarea clasică în C //Sunt specificate şi implementate două versiuni, //diferite prin lista de parametri ale funcţiei suma() #include <iostream.h> #include <conio.h>
//Prima versiune a funcţiei suma() //Parametrul s este transmis prin referinţă void suma(float &s,int o1,int o2) { s=o1+o2; }; //A doua versiune a funcţiei suma() //Parametrul s este transmis prin referinţă void suma(float &s,int o1, int o2, int o3) { s=o1+o2+o3; }; void main() { float st; clrscr(); //Utilizarea versiunii 2 suma(st,12,13,14); cout<<st<<endl; //Utilizarea versiunii 1 suma(st,12,13); cout<<st; getch(); }; Care sunt observaţiile care se impun? Mai întâi, este de remarcat faptul că trebuie să existe un programator care este suficient de informat cu privire la variaţiile de comportament ale unei funcţii având acelaşi nume şi care returnează acelaşi tip de dată. Deşi nu excludem posibilitatea de a întâlni o astfel de situaţie şi în alte contexte, programatorii versaţi ştiu foarte bine cât de mult valorează versionarea unei funcţii în programarea generică, atunci când soluţia template-urilor prezintă unele inconveniente. În al doilea rând, dacă versionarea este realizată cu simţ de răspundere, utilizarea diferitelor versiuni în diferite situaţii este extrem de comodă şi benefică, găsind o soluţie de partajare a codului versiunilor între mai multe programe. În al treilea rând, nu
putem trece cu vederea faptul că la compilare este realizată legarea unui apel de versiune de codul aferent (acest gen de legare se numeşte early binding). Polimorfismul orientat pe suprascrierea funcţiilor în cadrul definiţiei unei clase Această formă de polimorfism satisface unele cerinţe de versionare a comportamentului operaţiilor unei clase, în spiritul celor spuse relativ la suprascrierea în stil clasic a funcţiilor. După cum se anticipează în Exemplul 6.2 (de cod C++), acest tip de polimorfism poate fi combinat cu polimorfismul orientat pe supradefinirea metodelor într-un lanţ de derivare. Exemplul 6.2 #include <iostream.h> #include <conio.h> //Clasa de baza class Super { int numar; public: Super(int n) { numar=n; }; //Versiunea 1 a functiei f1() void f1() { cout<<"Super::Functie de test"<<endl; getch(); }; //Versiunea 2 a functiei f1() // Suprascrie prima versiune a lui f1() inauntrul //clasei Super //In raport cu clasa Baza f1()este virtuala //Deci urmeaza sa fie supradefinita virtual void f1(int n) { cout<<"Super::Numar: "<<n<<endl; getch(); };
}; //Clasa derivata class Baza:public Super { public: Baza(int n):Super(n) { }; void f1(int n) { cout<<"Baza::Numar: "<<n<<endl; getch(); }; }; void main() { //Pointer la Super Super *PSuper; //Alocare dinamică a memoriei pentru pointer-ul Psuper //în context Super PSuper=new Super(10); clrscr(); //Utilizare Psuper; apelare succesiva a doua versiuni ale //functiei f1() PSuper->f1(); PSuper->f1(10); delete PSuper; //Alocare dinamică a memoriei pentru pointer-ul PSuper //în context Baza PSuper=new Baza(12); PSuper->f1(12); delete Psuper; }; Polimorfsimul orientat pe supraîncărcarea programarea orientată pe obiecte
operatorilor
în
Subiect ocolit de specificatorii limbajului Java, însă generator de satisfacţii deosebite pentru programatorii în C++. Ideea de bază constă în faptul că este la latitudinea celor care programează orientat pe obiecte în C++ să redefinească comportamentul unui foarte mare număr de operatori (+, -, *, >>, <<, new, delete, etc.). Atenţie! Nu poate fi schimbată nici aritatea nici prioritatea operatorilor predefiniţi, prin supraîncărcare. Protocolul de supraîncărcare a unui operator, astfel încât acesta să opereze asupra obiectelor unei clase este următorul: 1.
Definiţia clasei trebuie să conţină o funcţie operator membru sau o funcţie operator prietenă, având sintaxa specială: Varianta funcţie membră <Tip returnat> operator # (<Lista de argumente>); sau Varianta funcţie friend friend <Tip returnat> operator # (<Lista de argumente>); În această sintaxă, atrag atenţia cuvântul cheie operator (care informează compilatorul că funcţia supraîncarcă un operator) şi caracterul # care semnifică un substitut pentru operatorul pe care doriţi să-l supraîncărcaţi, altul decât: “.” , “*” , “::” , “?” . De remarcat faptul că, alegând varianta funcţie membră, un operator binar va fi specificat ca o funcţie cu un parametru, care va indica operandul din stânga, operandul din dreapta fiind vizibil prin intermediul pointerului this. De asemenea, dacă alegem varianta funcţie membră, un operator unar va fi implementat ca o funcţie fără parametri, pointerul this permiţând referirea operandului. Defecţiunea în cazul utilizării unei funcţii membru pentru supraîncărcarea unui operator este clară: parametrul din stânga trebuie să fie un obiect, nu poate fi o constantă. Este evident că, în aceste condiţii funcţiile prietene sunt de preferat.
2.
Funcţiile operator se vor implementa folosind una din sintaxele:
<Tip returnat> <Nume clasă>::operator # (<Lista de argumente>) {
// Corp funcţie operator specificată ca membră }; sau <Tip returnat> operator # (<Lista de argumente>) { // Corp funcţie operator specificată ca prietenă }; Lucrurile pot fi înţelese şi mai bine, urmărind Exemplul 6.3 (cod C++). Exemplul 6.3 #include<conio.h> #include<iostream.h> //Clasa complex contine functia operator + ca membru //operatorul + este extins la multimea numerelor complexe //cu ajutorul unei metode membru a clasei complex //Clasa complex contine functia operator - ca functie friend //operatorul - este extins la multime numerelor complexe //cu ajutorul unei metode friend class complex { float x,y; public: complex(){}; complex(float a,float b) { static int i; i++; clrscr(); cout<<"Lucreaza constructorul...Obiectul->:"<<i; getch(); x=a; y=b; }; void disp_nc(); //prototipul operatorului + complex operator+(complex &op2);
//prototipul operatorului friend complex operator-(complex &op1,complex &op2); }; void complex::disp_nc() { cout<<x<<"+i*"<<y; }; //Implementare operator + //Aceasta sintaxa transforma apelul <ob1+ob2> //in +(ob2), ob1 fiind accesibil prin pointerul //special <this> complex complex::operator+(complex &op2) { complex temp; temp.x=op2.x+x; temp.y=op2.y+y; return temp; }; complex operator -(complex &op1,complex &op2) { complex temp; temp.x=op1.x-op2.x; temp.y=op1.y-op2.y; return temp; }; void main() { complex tamp1,tamp2; complex *pod1,*pod2; complex ob1(10,10),ob2(11,11); clrscr(); gotoxy(20,10);cout<<"Primul numar complex ->:"; ob1.disp_nc(); getch(); gotoxy(20,11);cout<<"Al doilea numar complex->:"; ob2.disp_nc(); getch();
ob1=ob1+ob2; gotoxy(20,13);cout<<"Suma numerelor complexe->:"; ob1.disp_nc(); getch(); pod1=new complex(200,200); pod2=new complex(300,300); tamp1=*pod1; clrscr(); gotoxy(20,10);cout<<"Al treilea numar complex tamp1.disp_nc();
->:";
tamp2=*pod2; gotoxy(20,11);cout<<"Al patrulea numar complex ->:"; tamp2.disp_nc(); gotoxy(20,14);cout<<"Suma numerelor complexe->:"; tamp1=tamp1+tamp2; tamp1.disp_nc(); tamp1=*pod1; tamp2=*pod2; tamp1=tamp1-tamp2; gotoxy(20,15);cout<<"Diferenta numerelor complexe->:"; tamp1.disp_nc(); getch(); } Polimorfismul orientat pe redefinirea funcţiilor în programarea orientată pe obiecte, într-un lanţ de derivare Este element suport esenţial pentru specializarea claselor într-un lanţ de derivare, specializare care se realizează prin redefinirea comportamentului unor metode ale strămoşilor. Pentru a se îmbina extinderea comportamentului cu reutilizarea codului, este de dorit ca redefinirea comportamentului să planifice utilizarea comportamentului versiunii din strămoş. Exemplul 6.4 ne arată cum se pune problema redefinirii în C++. Exemplul 6.4 #include <iostream.h> #include <conio.h>
//Structura suport pentru pastrarea //coordonatelor varfurilor poligoanelor struct Varf { int x,y; Varf *Legs; }; //Clasa Poligon //Clasă abstracta->nu are constructor şi destructor //Furnizează prototipurile metodelor definitie() şi arie() //ca metode virtuale pure. //Furnizează implementarea pentru metodele: // perimetru() // ElibMem() // setare_pvarfuri() // consultare_pvarfuri() // setare_nrvarfuri() // consulatre_nrvarfuri() class Poligon { Varf *pvarfuri; int nrvarfuri; public: //Metode virtuale pure //Vor fi redefinite în descendenţi virtual void definitie()=0; virtual float arie()=0; float perimetru(); void ElibMem(); void setare_pvarfuri(Varf *p); Varf * consultare_pvarfuri(); void setare_nrvarfuri(int nv) { nrvarfuri=nv; }; int consultare_nrvarfuri() { return nrvarfuri; };
}; float Poligon::perimetru() { cout<<"perimetru(): "; cout<<"Calculul perim. este neimplem... Poligon" <<endl; getch(); return 0; }; void Poligon::ElibMem() { Varf*pwork; while (pvarfuri!=NULL) { pwork=pvarfuri->Legs; delete pvarfuri; pvarfuri=pwork; }; }; void Poligon::setare_pvarfuri(Varf *p) { pvarfuri=p; }; Varf * Poligon::consultare_pvarfuri() { return consultare_pvarfuri(); }; //Clasa Triunghi //Clasă concretă având ca superclasă clasa Poligon //Redefineşte comportamentul metodelor: // definitie(); arie() //Furnizează constructor şi destructor class Triunghi:public Poligon { public: Triunghi(Varf *pt,int tnrv) {
setare_pvarfuri(pt); setare_nrvarfuri(tnrv); cout<<"Constructor Tringhi..."<<endl; }; virtual ~Triunghi() { cout<<"Destructor Triunghi..."<<endl;; ElibMem(); }; //Redefinire metode void definitie(); float arie(); }; void Triunghi::definitie() { cout<<"definitie(): "; cout<<"Triunghiul este poligonul cu trei laturi"<<endl; getch(); }; float Triunghi::arie() { cout<<"arie(): "; cout<<"Neimplementata deocamdata...Triunghi"<<endl; getch(); return 0; }; class Patrulater:public Poligon { public: Patrulater(Varf *pt,int tnrv) { setare_pvarfuri(pt); setare_nrvarfuri(tnrv); cout<<"Constructor Patrulater..."<<endl; }; //Destructor virtual virtual ~Patrulater();
void definitie(); float arie(); }; Patrulater::~Patrulater() { ElibMem(); cout<<"Destructor Patrulater..."<<endl; }; void Patrulater::definitie() { cout<<"definitie(): "; cout<<"Patrulaterul este poligonul cu patru laturi"<<endl; getch(); }; float Patrulater::arie() { cout<<"arie(): "; cout<<"Neimplementata deocamdata...Patrulater"<<endl; getch(); return 0; }; class Paralelogram:public Patrulater { public: Paralelogram(Varf *pt,int tnrv):Patrulater(pt,tnrv) { cout<<"Constructor Paralelogram..."<<endl; }; //Destructor virtual virtual ~Paralelogram() { ElibMem(); cout<<"Destructor Paralelogram..."<<endl; }; //Redefinire metode void definitie();
float arie(); }; void Paralelogram::definitie() { cout<<"definitie(): "; cout<<"Paralelogramul este patrulat. cu laturile paral. doua cate doua"<<endl; getch(); }; float Paralelogram::arie() { cout<<"arie(): "; cout<<"Neimplementata deocamdata...Paralelogram"<<endl; getch(); return 0; }; class Dreptunghi:public Paralelogram { public: Dreptunghi(Varf *pt,int tnrv):Paralelogram(pt,tnrv) { cout<<"Constructor dreptunghi..."<<endl; }; virtual ~Dreptunghi() { ElibMem(); cout<<"Destructor Dreptunghi..."<<endl; }; void definitie(); float arie(); }; void Dreptunghi::definitie() { cout<<"definitie(): "; cout<<"Dreptunghiul este cout<<endl; getch(); };
paralelogramul
cu
un
unghi
drept";
float Dreptunghi::arie() { cout<<"arie(): "; cout<<"Neimplementata deocamdata...Dreptunghi"<<endl; return 0; }; void main() { Poligon *RefPol; Patrulater *RefPatr; clrscr(); RefPol=new Triunghi(NULL,3); RefPol->arie(); RefPol->definitie(); RefPol->perimetru(); cout<<endl; delete RefPol; RefPatr=new Patrulater(NULL,4); RefPatr->arie(); RefPatr->definitie(); RefPatr->perimetru(); cout<<endl; delete RefPatr; RefPatr=new Paralelogram (NULL,4); RefPatr->arie(); RefPatr->definitie(); RefPatr->perimetru(); delete RefPatr; }; Pentru o mai bună înţelegere a Exemplului 6.4, sunt necesare o serie de precizări în ceea ce priveşte genul de polimorfism ilustrat. Mai întâi, din punct de vedere sintactic, trebuie să observăm faptul că informăm compilatorul de intenţia de redefinire a unei metode în aval (într-un lanţ de derivare) prin specificarea acesteia în clasa gazdă ca metodă virtuală sau ca metodă virtuală pură. Prototipul unei metode virtuale are sintaxa:
virtual <Tip returnat> <Nume metoda>([<Lista de parametri>]); Prototipul unei metode virtuale pure are sintaxa: virtual <Tip returnat> <Nume metoda>([<Lista de parametri>])=0; Clasele care conţin cel puţin o metodă virtuală pură sunt clase abstracte, deci nu pot avea instanţe directe, neavând nici constructori. În schimb, clasele abstracte pot fi folosite pentru a declara referinţe către descendenţi, ceea ce exte extrem de folositor dacă dorim polimorfism. De remarcat că redefinirea se bazează pe o restricţie importantă: în procesul de redefinire se conservă signatura (numărul de parametri, tipul lor şi tipul returnat). Odată ce o metodă a fost declarată virtuală sau virtuală pură, compilatorul ştie că această metodă este posibil să fie redefinită în descendenţi şi, de asemenea, compilatorul ştie că pentru clasa care conţine metode virtuale şi pentru toate clasele descendente ei, la crearea primului obiect, constructorul va crea şi tabela VMT (Virtual Methode Table), o structură partajată de toate obiectele unei clase, folosită de sistem pentru a realiza genul de legare a unui apel de codul contextual, numit late binding. Prin urmare, atunci când se crează un obiect, al cărui tip definitor este undeva într-un lanţ de derivare, dacă în amonte a existat intenţie de redefinire a unor metode, sistemul va crea, numai în cazul primului obiect de tipul respectiv, o tabelă care conţine adresele metodelor virtuale ale clasei. Aceste adrese vor fi utilizate în procesul de late binding. Să mai observăm faptul că, fără a fi prefixaţi de cuvântul cheie virtual, destructorii sunt apelaţi pe principiul “Întotdeauna lucrează constructorul tipului definitor al unei variabile obiect sau al unui pointer la un obiect”, ceea ce înseamnă un gen de legare statică a destructorului. Dacă dorim legare dinamică, atunci destructorul este declarat ca virtual. Efectul poate fi urmărit în Exemplul 6.4.
6.2 Polimorfismul în context Java Java implementează principiul polimorfismului la scara posibilităţilor proprii. În Java nu avem decât programare orientată pe obiecte, oricare ar fi calitatea acesteia. Astfel că se oferă suport pentru polimorfism orientat pe suprascrierea funcţiilor şi polimorfism orientat de supradefinire. Java nu oferă sintaxă pentru supraîncărcarea operatorilor, deci nu este posibil polimorfismul aferent. Merită să remarcăm faptul că supradefinirea în Java este mai simplă decât în C++, din punct de vedere sintactic vorbind. Pur şi sumplu, dacă
compilatorul sesizeză că în amontele unui lanţ de derivare există o metodă care este supradefinită în aval, atunci compilatorul generează informaţii necesare pentru realizarea legării la execuţie. Cerinţa conservării signaturii în procesul de supradefinire este prezentă şi în Java. Un model de utilizare a polimorfismului se poate observa în Exemplul 6.5. Exemplul 6.5 //Clasa radacina class Poligon { private String definitie; public Poligon(String d) { definitie=new String(d); }; public String citesteDefinitie() { return definitie; }; //Metoda va fi supradefinita in descendenti public void arie() { System.out.println("Poligon...neimplementata!"); }; }; class Triunghi extends Poligon { public Triunghi(String d) { super(d); }; //Supradefinire public void arie() { System.out.println("Triunghi...neimplementata!"); }; };
class Patrulater extends Poligon { public Patrulater(String d) { super(d); }; //Supradefinire public void arie() { System.out.println("Patrulater...neimplementata!"); }; }; public class Polimorf { public static void main(String[] s) { //Referinta la radacina Poligon PRef; //Alocare in context Poligon PRef=new Poligon("Linie franta inchisa"); System.out.println(PRef.citesteDefinitie()); //Sintaxe la utilizare este aceeasi in cele trei contexte PRef.arie(); System.out.println(""); //Alocare in context Triunghi PRef=new Triunghi("Poligonul cu trei laturi"); System.out.println(PRef.citesteDefinitie()); PRef.arie(); System.out.println(""); //Alocare in context Patrulater PRef=new Patrulater("Poligonul cu patru laturi"); System.out.println(PRef.citesteDefinitie()); PRef.arie(); System.out.println(""); }; };
Nu am motive să reiau discuţia pe marginea mecanismului de legare dinamică a metodelor supradefinite în Java. Chiar dacă compilatorul foloseşte alt gen de informaţii, la intrare, rezultatul final, pentru programator este acelaşi. Nu consider o problemă deosebită comentarea şi exemplificarea suprascrierii în clasele Java.
Capitolul 7 Tratarea structurată a excepţiilor în programarea orientată pe obiecte
7.1 O problemă, în plus, în programare: tratarea excepţiilor Programatorii adevăraţi trebuie să ia, obligatoriu, în calcul şi posibilitatea de a crea programe robuste, care fac faţă atât cerinţelor specificate dar nerafinate suficient, cât şi cerinţelor nespecificate dar formulate de utilizator, din diverse motive. Programele care au aceste calităţi se numesc robuste.
În programarea clasică, soluţia acestei probleme se putea numi, destul de exact spus, programare defensivă. Seamănă puţin cu conducerea preventivă din şoferie dacă ne gândim că programând defensiv, în fond punem răul înainte, deci nu ne bazăm pe cumsecădenia şi buna pregătire a utilizatorului. Încercarea de a trata situaţiile de excepţie care pot apare la execuţia unui program, folosind metode clasice (programarea defensivă) duce la creşterea semnificativă a complexităţii codului ceea ce afectează, în mod direct, lizibilitatea şi, în mod indirect, corectitudinea codului Pentru a face faţă cerinţelor legate de problema tratării excepţiilor (aşa se numesc în jargon profesional erorile care apar în timpul execuţiei programelor) anumite limbaje de programare oferă suport adecvat. Includem aici limbaje precum: Object Pascal, C++, Java, Visual C++. Nu toate compilatoarele de C++ oferă suport, dar standardul ANSI C++ cere acest lucru în mod explicit. Compilatoarele din familia Borland, începând cu versiunea 4.0 oferă acest suport. Esenţialul din punctul de vedere al programatorului C++ este ca el să-şi formeze abilitatea de a scrie, în jurul aplicaţiilor, cod C++ care îndeplineşte funcţia de handler de excepţii.
7.2 Mecanisme de bază în tratarea excepţiilor în C++ Suportul sintactic C++ pentru tratarea excepţiilor se rezumă la trei cuvinte cheie, a căror semantică preliminară o prezentăm în Tabelul 7.1. Semnificaţie Delimitează o porţiune de cod în care se instituie controlul sistemului asupra excepţiilor în timpul rulării. Lansează o excepţie de un anumit tip throw Captează o excepţie lansată catch Tabelul 7.1 Cuvintele cheie ale limbajului C++ referitoare la tratarea excepţiilor Cuvântul cheie try
Forma de bază a tratării excepţiilor Aşadar, atunci când programele dumneavoastră efectuează prelucrarea excepţiilor, trebuie să includeţi în cadrul unui bloc try instrucţiunile pe care doriţi să le monitorizaţi în eventualitatea apariţiei unei excepţii. Dacă execuţia unei instrucţiuni se termină cu o eroare, trebuie să lansaţi o eroare, corespunzătoare acţiunii funcţiei în care se află instrucţiunea. Programul
plasează instrucţiunea throw în cadrul blocului try-catch. Forma generalizată a blocului care captează şi tratează erorile este: try { //blocul try //if(eroare) throw valoare_excepţie; } catch (Tip_excepţie Nume_variabilă ){ //Prelucrarea excepţiei } În cadrul acestei forme generalizate, valoarea valoare_excepţie, aruncată, trebuie să corespundă tipului Tip_excepţie. Scrierea unui handler de excepţii simplu Pentru a înţelege mai bine semantica unui handler de excepţii, studiaţi programul prezentat în Exemplul 7.1. Exemplul 7.1 #include <iostream.h> void main() { cout<<"Start"<<endl; try { cout<<"In interiorul blocului try…"<<endl; throw 100; cout<<"Nu se va executa…niciodata"; } catch (int i) { cout<<"Am captat o excepţie --valoarea este:"; cout<<i <<endl; } cout<<"Sfarsit…"; }; Programul de mai sus implementează un bloc try-catch simplu. În loc să se aştepte ca programul să eşueze datorită unei erori, se utilizează instrucţiunea throw pentru lansarea erorii prezumtive. După ce blocul try lansează eroarea, blocul catch o captează şi prelucrează valoarea transmisă de instrucţiunea
throw. Este evident şi din acest exemplu că mecanismul try-throw-catch oferă suport pentru rezolvarea problemei tratării excepţiilor, dar nu rezolvă de la sine această problemă. Altfel spus, tratarea corectă a excepţiilor unui program este o problemă de atitudine ca proiectant şi ca programator. Lansarea excepţiilor cu o funcţie din cadrul blocului try Atunci când programele apelează funcţii din cadrul blocurilor try , C++ va transmite excepţia apărută într-o astfel de funcţie în afara funcţiei dacă nu există un bloc try în interiorul funcţiei. Exemplul 7.2 ne arată cum se petrec lucrurile într-o astfel de situaţie. Exemplul 7.2 #include <iostream> void XHandler(int test) { cout<<"Inauntrul functiei XHandler, test are valoarea: "<<test<<endl; if(test) throw test; }; void main() { cout<<"Start:"<<endl; try { cout<<"Inauntrul blocului try…"<<endl; XHandler(1); XHandler(2); XHandler(0); } catch(int i) { cout<<"Am captat o exceptie. Valoarea este:"; cout<<i<<endl; }; cout<<"Sfarsit"; }; Plasarea unui bloc try într-o funcţie Am văzut cum apare un bloc try în funcţia principală a unui program. C++ permite blocuri try şi în alte funcţii ale unui program, diferite de funcţia principală.
Atunci când se plasează un bloc try într-o funcţie, C++ reiniţializează blocul de fiecare dată când se intră în acea funcţie. Exemplul 7.3 ilustrează cele spuse. Exemplul 7.3 #include <iostream.h> void XHandler(int test) { try { if(test) throw test; } catch(int i) { cout<<"Am captat exceptia nr.: "<<i<<endl; } }; void main() { cout<<"Start: "<<endl; XHandler(1); XHandler(2); XHandler(0); XHandler(3); cout<< "Sfarsit"; }; Un comentariu pe marginea celor prezentate până acum ar fi următorul: o instrucţiune catch se execută numai dacă programul lansează o excepţie în cadrul blocului try situat imediat înainte. În caz că o astfel de excepţie nu se lansează blocul catch va fi ignorat. Utilizarea mai multor instrucţiuni catch cu un singur bloc try Pe măsură ce tratările excepţiilor devin tot mai complexe, uneori este necesar şi posibil ca un singur bloc try să lanseze excepţii de mai multe tipuri. În cadrul programelor dumneavoastră puteţi construi un handler de excepţii, astfel încât să accepte captarea mai multor excepţii. Într-o astfel de situaţie sintaxa generală este: try { //instrucţiuni
} catch (<tip_1> <var_1>) { //tratare excepţie 1 } catch(<tip_2> <var_2>) { //tratare excepţie 2 } : catch(<tip_n> <var_n>) { //tratare excepţie n } Cu acest amendament sintactic, deducem că instrucţiunile catch pot capta orice tip returnat, nu numai tipurile de bază acceptate de C++. Acest "fenomen" este ilustrat în codul de mai jos (Exemplul 7.4). Exemplul 7.4 #include <iostream.h> void XHandler(int test) { try { if(test==0) throw test; if(test==1) throw "Sir de caractere…"; if(test==2) throw 121.25; } catch(int i) { cout<<"Am captat exceptia #:"<<i<<endl; } catch(char *sir) { cout<<"Am captat exceptia de tip sir de caractere:" <<sir<<endl; } catch(double d)
{ cout<<"Am captat exceptia #:"<<d<<endl; } }; void main() { XHandler(0); XHandler(1); XHandler(2); cout<<"Sfarsit"; }; Blocuri catch generice (utilizarea operatorului puncte de suspensie) Programele scrise de dumneavoastră pot capta excepţii din cadrul mai multor blocuri try (de exemplu un bloc try care încapsulează mai multe funcţii care lansează excepţii diferite din blocuri try diferite, sau să utilizeze mai multe instrucţiuni catch într-un singur bloc try. C++ permite, de asemenea, utilizarea operatorului puncte de suspensie (…) pentru a capta orice tip de eroare care apare într-un singur bloc try. Sintaxa care permite captarea tuturor erorilor care apar într-un bloc try este prezentată mai jos. try { //Instructiuni } catch(…) { //tratarea exceptiei } Pentru exemplificare propun codul de mai jos (Exemplul 7.5). Exemplul 7.5 #include <iostream.h> void XHandler(int test) { try { if(test==0) throw test; if(test==1)
throw 'a'; if(test==2) throw 121.25; } catch(…) { cout<<"Am captat o exceptie"<<endl; } }; void main() { cout<<"Start:"<<endl; XHandler(0); XHandler(1); XHandler(2); cout<<"Sfarsit"; }; Evident, prelucrările din cadrul blocului catch generic trebuie să fie independente de tipul erorii. Mecanismul captării excepţiilor explicite poate fi combinat cu mecanismul excepţiilor generice ca în Exemplul 7.6. Exemplul 7.6 #include <iostream.h> void XHandler(int test) { try { if(test==0) throw test; if(test==1) throw 'a'; if(test==2) throw 121.25; } catch(int i) { cout<<"Am captat o exceptie de tip intreg…"<<endl; } catch(…) {
cout<<"Am captat o exceptie generica"<<endl; } }; void main() { cout<<"Start:"<<endl; XHandler(0); XHandler(1); XHandler(2); cout<<"Sfarsit"; }; Restricţionarea excepţiilor Pe măsură ce programele dumneavoastră devin mai complexe, ele vor apela frecvent funcţii din cadrul unui bloc try. Atunci când programele dumneavoastră apelează funcţii dintr-un bloc try, puteţi restricţiona tipurile de excepţii pe care funcţia apelată le poate lansa. De asemenea, puteţi preveni lansarea oricărei excepţii dintr-o anumită funcţie. Sintaxa pentru restricţionare este: <tip_returnat> <nume_functie>(<lista_arg>) throw(<lista_tipuri> ) { //Cod functie } Sintaxa care inhibă lansarea oricărei excepţii este: <tip_returnat> <nume_functie>(<lista_arg>) throw() { //Cod functie } Este bine să subliniem că atunci când declaraţi o funcţie cu clauza throw ea poate să lanseze doar acele tipuri precizate în listă. Dacă funcţia lansează orice al tip, programul este abortat. Un exemplu în continuare (Exemplul 7.7). Exemplul 7.7 #include <iostream.h> void XHandler(int test) throw(int, char, double) {
if(test==0) throw test; if(test==1) throw 'a'; if(test==2) throw 121.25; } void main() { cout<<"Start:"<<endl; try { XHandler(0); } catch(int i) { cout<<"Am captat un intreg…"<<endl; } catch(char c) { cout<<"Am captat un caracter…"<<endl; } catch(double d) { cout<<"Am captat un double…"<<endl; } cout<<"Sfarsit"; }; Relansarea unei excepţii În anumite situaţii poate fi necesar să se relanseze o excepţie din interiorul unui handler de excepţii. Dacă relansaţi o excepţie, C++ o va transmite unui bloc try exterior dacă acesta există. Cea mai probabilă situaţie în care puteţi opta pentru această variantă este atunci când doriţi să trataţi o excepţie în cadrul a două programe handler distincte. Pentru mai multă claritate, urmăriţi exemplul de mai jos (Exemplul 7.8). Exemplul 7.8 #include <iostream.h> void XHandler(void) {
try { throw "Salve…"; } catch(char *) { cout<<"Am captat char* in XHandler… "<<endl; throw; } void main() { cout<<"Start…"<<endl; try { XHandler(); } catch(char *) { cout<<"Am captat char * in main…"<<endl; } cout<<"Sfarsit…"; }; Mod de utilizare a excepţiilor Toate elementele prezentate au încercat să demonstreze că C++ are o atitudine activă faţă de problema tratării excepţiilor. Suportul oferit de C++ îl ajută pe programator să definească un comportament al programului când se produc evenimente anormale sau neaşteptate. O idee mai pragmatică de utilizare a suportului C++, în situaţii efective, o puteţi desprinde din Exemplul 7.9. Exemplul 7.9 #include <iostream.h> void div (double a, double b) { try { if(!b) throw b; cout<<"a/b="<<a/b<<endl; } catch(double b) {
cout<<"Nu se poate imparti la zero…"<<endl; } } void main() { double i,j; do { cout<<”Introduceti numaratorul (0 pentru stop):"<<endl; cin i; cout<<”Introduceti numitorul :"<<endl; cin j; div(i,j); } while (i!=0); }; Adevărata utilizare a protocoalelor prezentate mai sus, în context orientat pe obiect, se bazează pe posibilitatea de a modela, conform necesităţilor şi cerinţelor diferitelor tipuri de aplicaţii, excepţiile care pot apare. Practica a impus regula, potrivit căreia, fiecărei clase care face parte din diagrama claselor să îi asociem o clasă care îi modelează excepţiile posibile, în comportament. Astfel că diagrama claselor care însoţeşte soluţia unei aplicaţii orientate pe obiecte, va fi dublată de o diagramă a claselor care structurează excepţiile aplicaţiei. În acest mod putem ierarhiza excepţiile, le personalizăm şi, evident, le integrăm într-o concepţie unitară de planificare a robusteţii unei aplicaţii. Elementele de bază ale unei astfel de abordări sunt ilustrate în Exemplul 7.10, un exemplu de cod C-Builder, în care clasa Poligon este “acompaniată” de clasa ExPol, care, “ar trebui” să modeleze excepţiile care pot apare în evoluţia obiectelor de tip Poligon. Exemplul 7.10 //----------------------------------------------------------#include <vcl.h> #pragma hdrstop #include "UEx1.h" //----------------------------------------------------------#pragma package(smart_init) #pragma resource "*.dfm" //Clasa Poligon
//Insigfnifiantă din punct de vedere informaţional //şi comportamental //Expune către clienţi două metode: // constructorul Poligon() // metoda getmes() class Poligon { char *mes; public: Poligon(char m[]) { mes=new char[strlen(m)]; strcpy(mes,m); }; char * getmes() { return mes; }; }; //Clasa ExPol //Are în dotare operaţiile strict necesare creării //şi manipulării unui obiect excepţie: // -constructorul, care crează o excepţie (ExPol()) // -metoda de consultare de către client a // semnificaţiei excepţiei (getmes()) class ExPol { AnsiString mes; public: ExPol(char m[]) { mes=m; }; AnsiString getmes() { return mes; }; }; TForm1 *Form1;
Poligon *pol; //----------------------------------------------------------_fastcall TForm1::TForm1(TComponent* Owner) : TForm(Owner) { } //----------------------------------------------------------void _fastcall TForm1::Button1Click(TObject *Sender) { double numar; //Bloc de gardă, instituit de cuvântul cheie try try { numar=StrToFloat(Edit1->Text); if(numar) { pol=new Poligon("Testare"); Form1->Caption=pol->getmes(); } else // Creare şi aruncare obiect excepţie de tip ExPol throw new ExPol("Eroare alocare memorie obiect..."); } //Secvenţă de captare şi tratare //a excepţiilor de tip ExPol catch(ExPol *e) { Form1->Caption="Exceptie..."+e->getmes(); } } //----------------------------------------------------------Adevărul este că problema tratării excepţiilor în C-Builder este rezolvată mult mai bine decât în C++ standard, programatorul dispunând deja de o ierarhie de clase care modelează un mare număr de excepţii care pot apare, ierarhie la care se poate ralia şi programatorul pentru a defini şi trata propriile lui excepţii. Ne apropiem, astfel, destul de mult, de abordarea Java a problemei tratării excepţiilor.
7.3 Maniera Java de tratare a excepţiilor Aşa cum am menţionat deja, încercarea de a trata situaţiile de excepţie care pot apare la execuţia unui program, folosind metode clasice (programarea defensivă) duce la creşterea semnificativă a complexităţii codului, ceea ce afectează, în mod direct, lizibilitatea şi, în mod indirect, corectitudinea codului. Din această cauză cei care au creat Java au gândit un sistem de tratare a excepţiilor (în continuă evoluţie, de la o versiune la alta a limbajului Java) care să permită programatorului: Tratarea situaţiilor de excepţie, pe cât posibil, independent de fluxurile de control normale; Tratarea excepţiilor, la un nivel superior celui în care apar; Propagarea excepţiilor la nivelele superioare în mod ierarhic; Tratarea unitară a excepţiilor de acelaşi tip. În mare parte, asemănător sistemului C++ de tratare a excepţiilor, sistemul Java are, totuşi, o ofertă mai bine pusă la punct din acest punct de vedere. Java se bazează pe un număr restrâns de cuvinte cheie (try, catch, throw, finally, throws) şi pe o ierarhie de clase, specializate în tratrarea unor clase de erori. Pentru a înţelege mai bine mecanismul tratării excepţiilor în Java, consider că este utilă o scurtă descriere a modului în care apar şi sunt procesate excepţiile în Java. Astfel, când apare o excepţie în interiorul unei metode a unei clase Java, se creează un obiect excepţie (obiect ce caracterizează excepţia şi starea programului în momentul când excepţia apare). Odată creat acest obiect, el este “aruncat” utilizând cuvântul cheie throw. Sarcina creării şi aruncării obiectului excepţie aparţine programatorului. Din momentul aruncării unui obiect excepţie, folosind cuvântul cheie throw, maşina virtuală Java (JVM), prin componenta RuntimeSystem, preia obiectul şi îl transmite secvenţei de cod responsabilă de tratarea excepţiei respective. În acest scop, RuntimeSystem va căuta un handler al excepţiei (=o secvenţă de cod responsabilă de tratarea excepţiei), începând de la nivelul (nivelul este o metodă) în care a apărut excepţia şi continuând la nivelele superioare. Căutarea se face în urmă (backward), utilizând stiva de apel (call stack). Primul handler (un bloc try-catch), corespunzător obiectului excepţie, se va ocupa de soluţionarea excepţiei. Dacă RuntimeSystem a epuizat stiva de apel, fără a găsi o metodă care să ofere un handler al obiectului excepţie aruncat, RuntimeSystem va fi responsabil de tratarea excepţiei respective (va afişa un mesaj de eroare şi va opri firul de execuţie). Mecanismul descris mai sus poate fi vizualizat ca în Figura 19. Problema care se află în faţa programatorului este, evident, următoarea: cum poate folosi raţional mecanismul respectiv? Spun aceasta deoarece, ca
orice facilitate a limbajului şi suportul oferit pentru tratarea excepţiilor poate fi utilizat în mod abuziv. A abuza de tratarea excepţiilor înseamnă a vedea excepţii şi unde nu este cazul, fapt care provoacă complexificarea artificială a codului şi, foarte important, diminuează performanţele programului, deoarece, aşă cum rezultă şi din Figura 19, mecanismul tratării excepţiilor consumă resurse pentru a se desfăşura corespunzător. De aceea, este necesară o disciplinare a gândirii programatorului, în ceea ce priveşte decizia de a considera excepţie sau nu un anumit context de prelucrare, în interiorul unei metode şi apoi, decizia de a aborda, într-un anumit mod, problema tratării excepţiei respective. În esenţă, programatorul trebuie să acumuleze suficienţă experienţă încât să deosebească o excepţie nerecuperabilă de o excepţie din care se poate reveni. RuntimeSuystem - JVM -preia obiectul excepţie -caută, începând cu nivelul j, în sus, primul handler corespunzător (=primul handler care rezolvă o excepţie de tipul celei aruncate) -transferă obiectul excepţie handler-ului
Nivel_1
Nivel_i Nivel Tratare Excepţie Conţine un handler (un bloc try-catch) -preia obiect excepţie -tratează excepţie
Nivel_j Nivel apariţie excepţie -crează obiectul excepţie Exception exc=new Exception(); -aruncă excepţia throw(exc);
Figura 19. Mecanismul Java de tratare a excepţiilor Pentru a crea un obiect excepţie, Java pune la dispoziţia utilizatorului o ierahie de clase, aflată în pachetul java.lang. Pe lângă clasele de tip excepţie aflate în java.lang, fiecare pachet Java introduce propriile tipuri de excepţii. Utilizatorul însuşi poate defini clase de tip excepţie, care însă pentru a avea instanţe compatibile cu sistemul Java, trebuie să fie descendenţi ai clasei Throwable, clasă care ocupă o poziţie importantă în ierarhia simplificată a claselor de tip excepţie, prezentată în Figura 20.
Throwable
Exception
Error
RuntimeException
Figura 20. Ierarhia simplificată a claselor de tip excepţie din pachetul java.lang După cum se poate observa în Figura 20, clasa Throwable are doi descendenţi: clasa Error şi clasa Exception. Nici una din cele două clase nu adaugă metode suplimentare, dar au fost introduse pentru a delimita două tipuri fundamentale de excepţii care pot apare într-o aplicaţie Java (de fapt, acest mod de gândire este aplicabil în orice limbaj de programare care oferă suport pentru tratarea sistematică a excepţiilor). Clasa Error corespunde excepţiilor care nu mai pot fi recuperate de către programator. Apariţia unei excepţii de tip Error impune terminarea programului. Aruncarea unei excepţii de tip Error înseamnă că a apărut o eroare deosebit de gravă în execuţia programului sau în maşina virtuală Java. În marea majoritate a cazurilor, aceste excepţii nu trebuie folosite de către programator, nu trebuie prinse prin catch, şi nici aruncate prin throw de către programator. Aceste tipuri de erori sunt utilizate de JVM, în vederea afişării mesajelor de eroare. Clasa Exception este, de fapt, clasa utilizată efectiv de către programatori în procesul de tratare a excepţiilor. Această clasă şi descendenţii ei modelează excepţii care pot fi rezolvate de către program, fără a determina oprirea programului. Prin urmare, regula este simplă: dacă Java nu conţine o clasă derivată din Exception care poate fi utilizată într-un anumit context, atunci programatorul va trebui să o implementeze, el însuşi, ca o clasă derivată din Exception. Există o mare varietate de clase derivate din Exception care pot fi utilizate. Mai mult, fiecare pachet Java adaugă noi tipuri de clase derivate din
Exception, clase legate de funcţionalitatea pachetului respectiv. Dacă astfel lucrează cei de la SUN, de ce n-ar lucra la fel şi un programator oarecare? Din categoria claselor derivate din Exception, se remarcă clasa RuntimeException12 şi clasele derivate din ea. Din această categorie fac parte excepţii care pot apare în execuţia unui program, în urma unor operaţii nepermise de genul: operaţii aritmetice interzise (împărţire la zero), acces nepermis la un obiect (referinţă null), depăşirea index-ului unui tablou sau şir, etc. Nu ne rămâne decât să prezentăm protocolul de lucru cu excepţii în Java. Aruncarea excepţiilor Aruncarea unei excepţii se face cu ajutorul cuvântului cheie throw, conform sintaxei: … throw <obiectExceptie>; … unde <obiectExceptie> este o instanţa a clasei Throwable sau a unei clase, derivată din aceasta. Evident, în locul variabilei <obiectExceptie> poate fi o expresie care returnează un obiect de tip convenabil. De fapt, în practică, modul de aruncare a unei excepţii urmează schema: … throw new <clasaExceptie>(“Mesaj”); … Evident, putem avea şi cazuri în care o funcţie poate arunca în mod indirect o excepţie, ceea ce înseamnă că funcţia nu va conţine o expresie throw, ci va apela o funcţie care poate arunca o excepţie. O metodă poate arunca mai multe excepţii. Important este să înţelegem că prin aruncarea unei excepţii se iese din metodă fără a mai executa secventele de cod care urmau. În cazul în care o funcţie aruncă o excepţie, fie prin throw , fie prin apelul unei funcţii, fără a avea o secvenţă try-catch de prindere atunci această funcţie trebuie să specifice clar această intenţie în definiţia funcţiei. Pentru acest caz, sintaxa de definire a funcţie este: public void <numeMetoda> throws <clasExcept1>,<clasExcept2>, … { 12
Detalii cu privire la descendenţii clasei RuntimeException se pot găsi în Călin Marin Văduva, Programarea în Java, Editura Albastră, 2001.
… throw <obiectExcep1>; … throw <obiectExcept2>; … }; Prinderea excepţiilor Pentru a beneficia de avantajele mecanismului de tratare a excepţiilor, odată ce am aruncat o excepţie este nevoie să o prindem. Prinderea unei excepţii se face prin intermediul unui bloc try-catch, a cărui sintaxă generică este prezentată mai jos. … try { //Cod ce poate arunca o excepţie } catch(<clasExcept1> <idExcept1>) { //handler exceptie de tip <clasExcept1> } catch(<clasExcept2> <idExcept2>) { //handler exceptie de tip <clasExcept2> } … [finally { //secvenţa de cod executată oricum }] … După cum se poate observa, structura de prindere a excepţiilor poate fi delimitată în trei blocuri. Blocul try, numit şi bloc de gardă atrage atenţia că secvenţa de cod inclusă în el poate arunca, în anumite condiţii, excepţii. În cazul în care acest lucru nu se întâmplă, secvenţa din interiorul blocului de gardă se execută în întregime, controlul fiind predat primei instrucţiuni de după construcţia trycatch. În cazul în care se aruncă o excepţie, execuţia secvenţei din blocul de gardă se întrerupe şi se declanşează procedura de tratare a excepţiei.
Tratarea excepţiei se poate face prin intermeiul blocurilor catch, numite şi handlere de excepţii. În momentul în care apare o excepţie în regiunea de gardă, se parcurge lista blocurilor catch în ordinea în care apar în programul sursă. În cazul în care excepţia aruncată corespunde unui bloc catch, se execută codul eferent blocului şi se termină căutarea în listă, considerându-se că excepţia a fost rezolvată. Sintaxa ne arată că pot exista mai multe blocuri catch, ceea ce înseamnă că în blocul de gardă pot fi aruncate excepţii de mai multe tipuri. Situaţiile deosebite care pot apare în utilizarea blocurilor catch sunt următoarele: am putea dori să tratăm excepţii de tip EC1 şi EC2, unde EC2 este o clasă derivată din EC1. Datorită faptului că blocurile catch sunt parcurse secvenţial este necesar să avem handler-ul clasei EC2 înaintea handler-ului clasei EC1, altfel, nu se va ajunge niciodată la secvenţa catch de tratare a excepţiilor de tipul EC2. De asemenea, putem avea situaţii în care să dorim tratarea unei excepţii pe mai multe nivele. În acest caz, se poate lua în considerare faptul că, odată prinsă o excepţie într-un bloc catch, o putem re-arunca cu un apel simplu de tip throw. În sfârşit, blocul finally, dacă este folosit, cuprinde secvenţa de cod care se va executa, indiferent dacă apare sau nu o excepţie, situaţie reclamată de nenumărate contexte în care apariţia unei excepţii, ca şi lipsa acesteia, presupun rezolvarea unor probleme care pot scuti sistemul de introducerea unor elemente perturbatoare prin nerezolvarea lor. În Exemplul 7.11 şi în Exemplul 7.12 se pot vedea elementele de bază ale tratării excepţiilor într-o aplicaţie Java. Utilizarea cuvântului cheie finally nu mi se pare o problemă deosebită. Exemplul 7.11 //Metoda arunca o exceptie la nivelul superior //Clasa care modeleaza exceptiile clasei NumarReal class ENumarReal extends Exception { public ENumarReal(String s) { super(s); }; } //Clasa NumarReal //o tentativa de modelare a lucrului cu numere reale class NumarReal {
private double numar; public NumarReal(double nr) { numar=nr; }; public double getNumar() { return numar; }; //Metoda div() imparte doua numere reale //Suspecta de a arunca o exceptie la impartirea la zero //Declara acest lucru cu ajutorul cuvantului cheie throws public NumarReal div(NumarReal n) throws ENumarReal { if (n.getNumar()==0) throw new ENumarReal("Exceptie...Impartire la zero..."); else return new NumarReal(this.getNumar()/n.getNumar()); }; } public class Except1 { public static void main(String [] s) { //Blocul de garda care capteaza exceptia aruncata //de metoda div() try { NumarReal onr1=new NumarReal(12); NumarReal onr2=new NumarReal(6); System.out.println(onr1.div(onr2).getNumar()); onr1=new NumarReal(11); onr2=new NumarReal(0); onr1.div(onr2); } catch(ENumarReal e) {
System.out.println(e.getMessage()); }; }; }; Exemplul 7.12 //Metoda arunca o exceptie dar o si capteaza //Clasa care modeleaza exceptiile clasei NumarReal class ENumarReal extends Exception { public ENumarReal(String s) { super(s); }; } //Clasa NumarReal //o tentativa de modelare a lucrului cu numere reale class NumarReal { private double numar; public NumarReal(double nr) { numar=nr; }; public double getNumar() { return numar; }; //Metoda div() imparte doua numere reale //suspecta de a genera o exceptie la impartirea la zero //Are bloc try-catch pentru captarea si tratarea //exceptiei public NumarReal div(NumarReal n) { try { if (n.getNumar()==0) throw new ENumarReal("Exceptie...Impartire la zero...");
else return new NumarReal(this.getNumar()/n.getNumar()); } catch(ENumarReal e) { System.out.println(e.getMessage()); return new NumarReal(0); }; }; } public class Except { public static void main(String [] s) { NumarReal onr1=new NumarReal(12); NumarReal onr2=new NumarReal(6); System.out.println(onr1.div(onr2).getNumar()); onr1=new NumarReal(11); onr2=new NumarReal(0); onr1.div(onr2); }; };
Capitolul 8 Programare generică în C++ şi Java
8.1 Ce este programarea generică Adeseori, programatorul se află în situaţia de a efectua acelaşi tip de prelucrare asupra unor tipuri de date diferite. Soluţia începătorului este “scrierea de cod complet pentru fiecare tip de dată”. Vrem să sortăm un fişier după o cheie întreagă? Scriem o funcţie care realizează acest lucru, folosind, de exemplu, metoda bulelor. Vrem să sortăm un fişier după o cheie alfanumerică? Scriem o funcţie care ştie să sorteze fişierul după o astfel de cheie, folosind tot metoda bulelor. Nu ne va fi greu să observăm că, în cele două rezolvări date de noi există un element de invarianţă: codul şablon care efectuează sortarea. Deosebirile se referă la tipurile de date implicate în procesul de sortare (fişierele pot avea înregistrări de lungime diferită şi, evident, cu structură diferită iar cheile de sortare pot fi diferite ca tip). Problema în faţa căreia ne aflăm nu este o problemă de algoritmică ci una de tehnică de programare. Programarea care are în vedere specificarea unor structuri de prelucrare capabile să opereze asupra unor tipuri variate de date se numeşte programare generică. Evident, există limbaje de programare în specificarea cărora au fost prevăzute şi elemente suport pentru rezolvarea acestui tip de problemă. De exemplu, în Object Pascal se poate face programare generică apelând la tipuri procedurale şi la referinţele de tip pointer. În C++ se pot utiliza, în scopuri generice, suprascrierea funcţiilor, conceptul de pointer, funcţiile şablon sau clasele şablon şi pointerii la funcţii. În sfârşit, în Java, utilizând cu abilitate moştenirea şi interfeţele putem simula genericitatea de o manieră destul de acceptabilă. Aş evidenţia, dintre toate tipurile de elemente suport prezentate mai sus, clasele şablon din C++, socotite abstracţii foarte puternice, care permit simularea a ceea ce, în ingineria softului, numim metaclase.
8.2 Genericitatea în C++ Aşadar, in programare apar nenumărate situaţii în care reutilizarea codului presupune o soluţie de un anumit tip pentru o problemă dată. Situaţia la care ne referim în această secţiune este, potenţial vorbind, următoarea: Ce putem face pentru a comprima codul sursă în situaţia în care structuri de date, diferite ca tip, suportă prelucrări similare. Soluţia acestei probleme de stil de programare o reprezintă programarea generică. Exprimându-ne în termenii limbajului C, o funcţie generică defineşte un set general de operaţii care vor fi aplicate unor tipuri de date diferite. Ca un exemplu, o soluţie generică pentru modelarea unei stive este un pretext ideal pentru precizarea ideilor principale ale programării generice. Altfel spus, dacă dorim o stivă, în care, de la caz la caz, să putem păstra
numere întregi, numere reale sau şiruri de caractere (deci tipuri de date diferite), operaţiile fiind aceleaşi ( push() şi pop() ), este clar că ne aflăm în situaţia în care avem nevoie de suport pentru scrierea de cod cu proprietăţi generice. Dacă în Pascal programarea generică se baza pe tipuri procedurale şi programarea la nivel de octet, în C++ există suport evoluat pentru programare generică, sub forma şabloanelor. Cu un şablon, în C++ se poate crea o funcţie generică sau o clasă generică. Să reamintesc cititorului faptul că în Capitolul 4 am ilustrat modul de utilizare a conceptului de pointer la funcţie, pentru definirea unui membru al unei structuri. Funcţii TEMPLATE O funcţie template este o funcţie şablon, având unul sau mai mulţi parametri formali de un tip generic. În funcţie de nevoile de utilizare a acestei funcţii, compilatorul generează funcţii propriu-zise, înlocuind tipul generic cu un tip concret. Tipul concret poate fi orice tip fundamental, derivat sau clasă. Considerăm un exemplu. Fie funcţia max(x,y) care returnează valoarea maximă a argumentelor sale. Tipul variabilelor x şi y trebuie, obligatoriu, specificat în momentul compilării. Soluţia clasică constă în redefinirea (overloading) funcţiei max pentru fiecare tip al argumentelor x şi y. Trebuie, aşadar, să definim mai multe versiuni ale funcţiei max. int max(int x, int y) { return (x>y) ? x : y; } float max(float x, float y) { return (x>y) ? x : y; } Mecanismul template permite definirea o singură dată a şablonului de funcţii, după care se generează automat funcţiile propriu-zise, în concordanţă cu necesităţile de utilizare, dar, evident, în faza de compilare. Sintaxa la specificare este: template <class Nume_tip_generic_1 [,…class Nume_tip_generic_n]> Nume_şablon definiţie_şablon
De precizat următoarele: Caracterele < şi > fac parte din sintaxa obligatorie, nu îndeplinesc, aşa cum ne-am obişnuit, rolul de elemente de metalimbaj. Lista de parametri formali ai unei funcţii şablon trebuie să utilizeze toate tipurile de date generice. În cazul funcţiilor template nu se fac conversii. Funcţia care are acelaşi nume şi acelaşi număr de parametri cu o funcţie şablon se numeşte caz exceptat (Supraîncărcarea explicită este prioritară). Sintaxa la utilizare este: Nume şablon(Expresie_1[, …,Expresie_n]); Prezentăm, în continuare, definiţia funcţiei şablon max , urmată de o secvenţă client de utilizare. template <class T> T max(T x, T y) { return (x>y) ? x : y; } Exemplul 8.1 #include <conio.h> #include<iostream.h> //Definire sablon functie template<class T> T max(T x,T y) { return(x>y) ? x:y; } void main() { int i,j; float k,l; clrscr();
i=10; j=2; k=13; l=-7; //Exemple de utilizare sablon gotoxy(20,10); cout<<"Apel max cu param. variabili de tip float..." << max(k,l); gotoxy(20,12); cout<<"Apel max cu param. variabili de tip int ..."<<max(i,j); gotoxy(20,13); cout<<"Apel max cu parametri valoare …float ..."<<max(13.,-7.); getch(); } Prezentăm, totodată, un exemplu de funcţie generică pentru compararea unor date după valoarea unei chei încapsulate în aceste date. Exemplul 8.2 #include <conio.h> #include <iostream.h> #include <ctype.h> //Definirea unei <functii generice> pentru compararea //unor date dupa valoarea unei chei incapsulate //in aceste date template <class T> int comp(T i1,T i2) { if (i1.key<i2.key) return -1; if (i1.key==i2.key) return 0; if (i1.key>i2.key) return 1; }; //Structura aleasa pentru exemplificare //cheia generica incapsulata este campul <key> struct tpers { char np[30]; int key; };
//Instantiere <struct tpers> struct tpers tam,pers[50]; void main() { clrscr(); int i=0; //Citire persoane de la tastatura do { gotoxy(20,12); cout<<"Numele persoanei:";clreol(); cin>>pers[i].np; gotoxy(20,13); cout<<"Matricola:";clreol(); cin>>pers[i].key; gotoxy(20,14); cout<<"Mai aveti(D,N):"; i++; } while(toupper(getch())!='N'); //Listare persoane pe ecranul monitorului //in ordinea citirii de la tastatura clrscr(); cout<<"Listare pers. in ordinea citirii de la tastatura"<<endl; cout<<"_________________________________________"<<endl; for(int j=0;j<i;j++) { if (wherey()>10) { cout<<"_________________________________________"<<endl; cout<<"Pentru continuare apasati o tasta..."<<endl; getch(); clrscr(); cout<<"Listare pers. in ord. citirii de la tastatura"<<endl; cout<<"_________________________________________"<<endl; }; cout.width(30);cout.setf(ios::left); cout<<pers[j].np<<" "<<pers[j].key<<endl; }; getch();
//Sortare persoane int sortat; do { sortat=1; for(int j=0;j<i-1;j++) { switch(comp(pers[j],pers[j+1])) { case 1: { tam=pers[j]; pers[j]=pers[j+1]; pers[j+1]=tam; sortat=0; }; }; }; } while(!sortat); //Listare persoane dupa sortare in ordinea //crescatoare a matricolelor clrscr(); cout<<"Listare persoane dupa sortare..............."<<endl; cout<<"_______________________________________"<<endl; for(int k=0;k<i;k++) { if (wherey()>10) { cout<<"_________________________________________"<<endl; cout<<"Pentru continuare apasati o tasta..."<<endl; getch(); clrscr(); cout<<"Listare persoane dupa sortare............"<<endl; cout<<"_________________________________________"<<endl; }; cout.width(30);cout.setf(ios::left); cout<<pers[k].np<<" "<<pers[k].key<<endl; }; getch();
} Clase TEMPLATE O clasă template defineşte un şablon pe baza căruia se pot genera clase propriu-zise. Din acest motiv, o clasă template se mai numeşte şi clasă generică , clasă generator sau metaclasă. Astfel că, o clasă template devine o clasă de clase, reprezentând cel mai înalt nivel de abstractizare admis de programarea obiect orientată în C++. În cadrul clasei şablon se pot declara atribute informaţionale de un tip ambiguu, care sunt particularizate în cadrul clasei generată pe baza şablonului. Evident, şi în acest caz, generarea se face în faza de compilare în concordanţă cu cerinţele clientului. Sintaxa la specificarea unei clase şablon, în cazul în care avem un singur tip generic este: template <class T> class nume_clasa { : }; Extinderea la mai multe tipuri generice este imediată. Sintaxa după care se face implementarea funcţiilor membre ale unei clase template: template <class T> Tip returnat nume_clasa <T>::nume_funcţie(…) { : }; Sintaxa la instanţiere este: nume_clasa <Tip_concret> Obiect; Pentru mai multă claritate, prezentăm şi exemplul de mai jos. Exemplul 8.3 #include <iostream.h> #include <stdlib.h>
#include <conio.h> const int SIZE = 10; //Definire clasa matrice generica template <class ATip> class genmat { ATip a[SIZE]; public: genmat(); ATip &operator [ ](int i); //Supraテョncトビcare operator [ ] }; //Implementare constructor clasa generica template <class ATip> genmat<ATip>::genmat() { register int i; for(i=0;i<SIZE;i++) a[i]=i; }; //Implementare supraincarcare operator [ ] template <class ATip> ATip &genmat<ATip>::operator[ ](int i) { if(i<0 ||i>SIZE-1) { cerr<<"Valoare indice eronata..."; getch(); exit(1); }; return a[i]; }; //Functia principala void main() { genmat<int> intob; genmat<double> doubob; int i; clrscr(); cout<<"Matrice de intregi..."<<endl;
for(i=0;i<SIZE;i++) intob[i]=i; for(i=0;i<SIZE;i++) cout<<intob[i]<<endl; getch(); clrscr(); cout<<"Matrice de reali dubla precizie..."<<endl; for(i=0;i<SIZE;i++) doubob[i]=(double)i/3; for(i=0;i<SIZE;i++) cout<<doubob[i]<<endl; getch(); clrscr(); intob[100]=100; }; Tot pentru exemplificare, să considerăm şi o situaţie deosebit de simplă. Ni se cere să construim o clasă template corespunzătoare conceptului de stivă, din care, ulterior, să se poată concretiza clase care simulează stiva pentru tipuri de date diferite. //Clasa sablon CSTack template <Class T> class CStack { T * v; //pointer la varful stivei T * p; //pointer la pozitia curenta din stiva int dim; //dimensiunea stivei public: CStack(int sz) { v=p=new T [dim=sz]; //alocare dinamica de memorie pentru p şi v } ~CStack() { delete [ ] v; } void push(T a) { *p++=a; }; T pop()
{ return *-p } } După ce o astfel de clasă template a fost declarată şi definită, se poate trece deja la instanţierea de obiecte. Singura deosebire faţă de folosirea unei clase obişnuite constă în faptul că trebuie specificat tipul concret care înlocuieşte tipul generic T. Ca un exemplu, să instanţiem un obiect stivă (de tip CStack) , în care încap maximum 100 elemente de tip char. CStack<char> sc(100); În acest caz, compilatorul generează clasa ce rezultă prin înlocuirea lui T cu char, după care instanţiază obiectul sc. Constructorul acestuia primeşte ca argument valoarea 100. Pentru mai multe detalii urmăriţi exemplul de mai jos.
Exemplul 8.4 #include <iostream.h> #include <conio.h> // Definirea clasei stack. Se vede că instanţele ei nu sunt //protejate faţă de excepţii. template <class T> class CStack { T *v; T *top; int dims; public: //Constructor CStack( int sz) { v=top=new T[dims=sz]; } //Desctructor ~CStack() { delete [ ] v; }
//Inserare elemente in stiva void push(T a) { *top++=a; } //Extragere elemente dinstiva T pop() { return *--top; } }; //Functia principala void main() { int i; //Primul exemplu de instantiere a stivei generice ;numere întregi CStack<int> st1(20); //Încarcare stiva for (i=0;i<=9;i++) { st1.push(i); } //Vizualizare continut stiva clrscr(); for (i=0;i<=9;i++) { gotoxy(35, wherey()+1); cout<<st1.pop()<<"*****"; } getch(); //Al doilea exemplu de instantiere a stivei generice; caractere, //începand cu “A” CStack<char> st2(20); //Încarcare stiva for (i=65;i<75;i++) {
st2.push((char) i); } //Vizualizare continut stiva clrscr(); for (i=0;i<10;i++) { gotoxy(35, wherey()+1); cout<<st2.pop()<<"*****"; } getch(); } Exemplul 8.5 combină puterea şabloanelor cu puterea pointerilor pentru a arăta cum se poate regăsi elementul de pe o poziţie indicată, într-o colecţie omogenă de date. Exemplul 8.5 //T este tipul de bază al colecţiei //buf conţine colecţia de elemente de tip T //poz indica pozitia elementului care ne intereseaza, 0-bazata #include <iostream.h> #include <conio.h> //Functia template template <class T> T retkey(T *buf,int poz) { for(int i=0;i<poz;i++) buf++; return *buf; }; void main() { int nr; int *tab; int *wtab; tab=new int(4); wtab=tab; *tab++=0; *tab++=1;
*tab++=2; *tab=3; clrscr(); //Utilizare functie template nr=retkey(wtab,2); cout<<nr; getch(); }; Exemplul 8.6 schiţează soluţia C/C++ pentru problema despachetării unei înregistrări, dacă operaţiile de citire se fac la nivel de octet. Un astfel de instrument este absolut necesar dacă vrem să scriem cod C/C++ pentru sortarea unui fişier oarecare, cu înregistrarea de lungime fixă, a cărui cheie se specifică prin poziţie (octetul de început, primul câmp începând la octetul 0). Exemplul 8.6 #include <iostream.h> #include <conio.h> #include <alloc.h> struct Stud { int matr; char nume[15]; float bursa; int varsta; }; void *retcamp(void *buf,int poz,int ltot) { unsigned char *rec; rec=(char*)malloc(ltot); memmove(rec,buf,ltot); for(int i=0;i<poz;i++) rec++; return (void*)rec; }; void main() { //Declarare initializare inregistrare
Stud s={12,"Vulcanescu",10000,35}; //Declarare pointer generic void *sir; //Pointer receptor la casting catre float float *nrf; //Pointer receptor la casting catre int int *nri; //Alocare dinamica memorie pentru pointerii de mai sus sir=malloc(15); nrf=(float*)malloc(4); nri=(int*)malloc(2); //Utilizare retcamp() pentru recuperare nume student sir=retcamp((void *)&s,2,21); clrscr(); cout<<(char*)sir<<endl; getch(); //Utilizare retcamp() pentru recuperare bursa student sir=retcamp((void*)&s,17,21); nrf=(float*)sir; cout<<*nrf<<endl; getch(); //Utilizare retcamp() pentru recuperare varsta student sir=retcamp((void*)&s,21,23); nri=(int*)sir; cout<<*nri; getch(); }; Pointeri la funcţii O sursă interesantă de genericitate, în programarea C++ o reprezintă şi pointerii la funcţii sau la metodele membre ale unor clase. Declararea unei variabile pointer la o funcţie sau la o metodă membră a unei clase se bazează pe o sintaxă care iese oarecum din cadrul general de declarare a variabilelor în C/C++. În esenţă, declaraţia unei variabile pointer la funcţie corespunde sintaxei de mai jos:
<Tip returnat> (*<NumeVariabila>)([<Lista parametri>]); Utilizarea acestei sintaxe suport şi câteva elemente despre semantica utilizării pointerilor la funcţii în Exemplul 8.7. Exemplul 8.7 #include <iostream.h> #include <conio.h> //Declarare pointer la functie //Numele pointerului este comp //comp este o variabila care poate pastra //adrese catre functii de comparare a doua valori numerice int (*comp)(void*,void*); //Functie de comparare specializata in numere intregi int icom(void *o1,void *o2) { if(*(int*)o1<*(int*)o2) return -1; if(*(int*)o1==*(int*)o2) return 0; if(*(int*)o1>*(int*)o2) return 1; }; //Functie de comparare specializata in numere reale //virgula mobila simpla precizie int fcom(void *o1,void *o2) { if(*(float*)o1<*(float*)o2) return -1; if(*(float*)o1==*(float*)o2) return 0; if(*(float*)o1>*(float*)o2) return 1; }; void main() { int*ip1=new int(100); int*ip2=new int(120); float*fp1=new float(1.75); float*fp2=new float(1.50); //Utilizare pointer la functie cu versiunea icom comp=&icom; clrscr(); cout<<comp(ip1,ip2);
getch(); //Utilizare pointer la functie cu versiunea fcom comp=&fcom; clrscr(); cout<<comp(fp1,fp2); getch(); };
8.3 Genericitatea în Java Java nu dispune de pointeri şi de template-uri. S-ar putea crede că genericitatea este dificilă sau aproape imposibilă în Java. Adevărul este că lucrurile nu stau chiar aşa. În programarea orientată pe obiecte Java, putem combina forţa referinţelor la clase cu puterea oferită de moştenire şi interfeţe pentru a obţine un suport interesant pentru programarea generică. Moştenirea ajută la crearea cadrului organizat în care putem specifica mai multe tipuri de obiecte asupra cărora efectuăm aceleaşi prelucrări. Conversiile down, permise între “rubedeniile” unei ierarhii de clase sunt esenţiale pentru a implementa genericitatea. Interfeţele ajută la specificarea cadrului natural de introducere, în Java, a referintelor la metodele membre ale unor clase. Exemplul 8.8 ilustrează rolul moştenirii în scrierea de cod Java pentru crearea şi vizualizarea unei liste simplu înlănţuite generice. Exemplul 8.9 ilustrează simularea pointerului la o metodă generică, în Java, cu ajutorul unei instanţe a unei clase singleton. Exemplul 8.10 ilustrează simularea pointerului la o metodă generică, în Java, cu ajutorul interfeţelor. Exemplul 8.8 //Clasa care modeleaza nodul listei //capabil sa pastreze orice tip de data class Nod { private Object inf; Nod legs; public Object read() { return inf; }; public void write(Object x) { inf=x; };
} //Clasa care modeleaza comportamentul //unei liste simplu inlantuite class Lista { Nod start; Nod prec; public Lista() { start=null; }; public void adaugdupa(Object on) { if(start==null) { Nod tamp=new Nod(); start=tamp; start.legs=null; start.write(on); prec=start; } else { Nod tamp=new Nod(); tamp.write(on); prec.legs=tamp; tamp.legs=null; prec=tamp; } } //Metoda nu respecta cerintele //care i-ar da dreptul sa figureze //in API-ul clasei. //Am specificat-o din motive didactice. //Se poate observa un prilej potrivit pentru utilizarea //enuntului instance of //In intentie, aceasta metoda este un iterator public void PentruToate() { Nod w=start;
int tip=0; Integer i; String s; do { if(w.read() instanceof Integer) tip=1; if(w.read() instanceof String) tip=2; switch(tip) { case 1: { i=(Integer)(w.read()); System.out.println(i);break; } case 2: { s=(String)(w.read()); System.out.println(s);break; } }; w=w.legs; } while(w!=null); } } public class CreLisGen { public static void main(String[] arg) { Nod obiect; Lista lis=new Lista(); for(int i=0;i<8;i++) { obiect=new Nod(); obiect.write(new Integer(i).toString()); lis.adaugdupa(obiect.read()); obiect=new Nod(); obiect.write(new Integer(i)); lis.adaugdupa(obiect.read());
} lis.PentruToate(); } } Exemplul 8.9 //Clasa pretextInt introduce o strategie concreta de comparare //relativ la numere intregi //Exemplu de clasa SINGLETON class pretextInt { //Constructor privat //pentru a asigura caracterul de singleton private pretextInt() { }; public static final pretextInt INSTANCE=new pretextInt(); public int comp(Object a,Object b) { Integer ia,ib; ia=(Integer)a; ib=(Integer)b; if(ia.intValue()<ib.intValue())return -1; if(ia.intValue()==ib.intValue())return 0; else return 1; }; }; //Clasa pretextInt introduce o strategie concreta de comparare //relativ la numere reale in virgula mobila //Exemplu de clasa SINGLETON class pretextFlo { //Constructor privat //pentru a asigura caracterul de singleton private pretextFlo() { };
public static final pretextFlo INSTANCE=new pretextFlo(); public int comp(Object a,Object b) { Float fa,fb; fa=(Float)a; fb=(Float)b; if(fa.floatValue()<fb.floatValue())return -1; if(fa.floatValue()==fb.floatValue())return 0; else return 1; }; }; public class Simpfunc { public static void main(String[] s) { Integer nri1=new Integer(100); Integer nri2=new Integer(200); Float nrf1=new Float(1.75); Float nrf2=new Float(1.0); System.out.println(pretextInt.INSTANCE.comp(nri1,nri2)); System.out.println(pretextFlo.INSTANCE.comp(nrf1,nrf2)); }; }; Exemplul 8.10 //Interfata prin intermediul careia se va simula //ideea de pointer la metoda comp interface SimPointMet { int comp(Object a,Object b); }; //Clasa gazda a primei versiuni a metodei comp //Va utiliza interfata SimPointMet class pretextInt implements SimPointMet { public pretextInt() { };
public int comp(Object a,Object b) { Integer ia,ib; ia=(Integer)a; ib=(Integer)b; if(ia.intValue()<ib.intValue())return -1; if(ia.intValue()==ib.intValue())return 0; else return 1; }; }; //Clasa gazda a celei de-a doua versiuni a metodei comp //Va utiliza interfata SimPointMet class pretextFlo implements SimPointMet { public pretextFlo() { }; public int comp(Object a,Object b) { Float fa,fb; fa=(Float)a; fb=(Float)b; if(fa.floatValue()<fb.floatValue())return -1; if(fa.floatValue()==fb.floatValue())return 0; else return 1; }; }; public class PointMet { public static void main(String[] s) { //Interfata SimPointMet lucreaza in context pretextInt Integer ni1,ni2; Float nf1,nf2; SimPointMet INSTANCE1=new pretextInt(); //Interfata SimPointMet lucreaza in context pretextFlo
SimPointMet INSTANCE2=new pretextFlo(); ni1=new Integer(100); ni2=new Integer(200); nf1=new Float(200); nf2=new Float(100); System.out.println(INSTANCE1.comp(ni1,ni2)); System.out.println(INSTANCE2.comp(nf1,nf2)); }; };
Capitolul 9 Fluxuri orientate pe obiecte. Perspectiva C++
9.1 Noţiunea de flux Una din problemele importante, care trebuie rezolvată într-o aplicaţie reală, este problema asigurării persistenţei datelor. Această problemă este rezolvată în mod diferit de limbaje diferite. De fapt, este vorba despre imaginarea unor strategii de păstrare, cu ajutorul memoriilor externe, a rezultatelor prelucrărilor. Memoria internă fiind foarte rapidă în acces, dar volatilă, este mediul ideal pentru reprezentarea unor structuri de date cât mai flexibile şi cât mai eficiente la interogarea şi în timpul prelucrării datelor. Prelungirea duratei de viaţă a colecţiilor de date (care necesită această prelungire a duratei de viaţă), se bazează pe utilizarea unei varietăţi convenabile de memorie externă, mai lentă din punct de vedere al accesului, dar având capacitatea de a păstra colecţiile de date şi după terminarea execuţiei programelor. Oricât ar părea de ciudat, rezolvarea acestei probleme implică o serie de resurse hard şi soft, precum: echipamente periferice adecvate, controllere, sistem de gestiune a fişierelor, driver-e, cod sursă adecvat. Putem efectua operaţii de intrare-ieşire la nivel fizic, eludând, pur şi simplu, drivere şi chiar sistemul de gestiune a fişierelor. Nu cred că merită să ne asumăm astfel de responsabilităţi, deoarece diminuăm o serie de calităţi de bază ale sistemelor soft( portabilitatea, lizibilitatea şi extensibilitatea fiind cel mai mult afectate). După o evoluţie îndelungată a căutărilor în această direcţie, s-a ajuns la două mari tipuri de rezolvări elegante ale problemei: soluţia fişiere-limbaj de nivel înalt şi soluţia fişier-SGBD. Prima soluţie este tributară ideii de a lăsa programatorului suficientă libertate de mişcare în procesul de manipulare a fişierelor. Preţul libertăţii este o dependenţă prea strânsă a aplicaţiilor de structura fişierelor. A doua soluţie este tributară ideii de a oferi aplicaţiilor o cât mai mare independenţă faţă de structura fişierelor, precum şi soluţii predefinite pentru o serie de tipuri standard de operaţii specifice manipulării fişierelor şi colecţiilor de fişiere (sisteme de fişiere, care împreună cu aplicaţiile aferente formează ceea ce numim baze de date). Limbajele de nivel, de regulă, nu oferă cuvinte cheie pentru efectuarea de operaţii I/O. Obişnuinţa este de a oferi biblioteci de operaţii care permit efectuarea operaţiilor I/O. În condiţiile în care diversitatea echipamentelor care ocazionează efectuarea operaţiilor I/O este mare, este firească preocuparea specificatorilor limbajelor de programare de nivel înalt de a institui protocoale unitare de efectuare a operaţiilor I/O. În C/C++, conceptul care permite o abstractizare convenabilă a operaţiilor I/O, în ideea tratării unitare, este conceptul de flux (stream). Prin introducerea acestui concept, programatorul lucrează cu o abstracţie logică (fluxul), nu direct cu fişierul extern, care, în funcţie de echipament, poate avea diferite forme de manifestare. Îm esenţă, un flux este un canal de comunicaţie între o sursă de date şi un receptor. Fluxul este o abstracţie logică, care asigură un anumit tip de funcţionalitate la vedere, dar instituie şi reguli, invizibile pentru programator, dar utile pentru
optimizarea operaţiilor I/O, precum utilizarea zonelor tampon (buffere) în cursul operaţiilor I/O (vezi Figura 21).
< < cout este legat la ecranul consolei
Buffer asociat cu fluxul de ieşire cout
Operatorul << converteşte reprezentarea internă în caractere
…000 0000 0110 01000101000010…
Reprezentarea unei variabile în memorie
Figura 21. Utilizarea buffer-ului în operaţiile specifice fluxului cout Necesitatea zonelor tampon asociate operaţiilor I/O este indiscutabilă, în condiţiile în care există, în mod sistematic, diferenţe semnificative de viteză de lucru între sursa de date a unui flux şi receptorul datelor fluxului. Aşadar, programatorul are nevoie de un instrument logic care să simplifice la maximum posibil, operaţiile I/O şi să le optimizeze în mod rezonabil. Acest instrument, în cazul limbajelor de nivel înalt, este fluxul C++ şi Java oferă programatorilor colecţii structurate de clase a căror colaborare furnizează comportamentul primitiv necesar pentru realizarea de operaţii I/O flexibile. 9.2 Fluxurile C++ Sistemul de fişiere din C şi C++ este proiectat să lucreze cu o mare varietate de echipamente, care include: terminale, drivere de disc, drivere de unitate de bandă, etc. Chiar dacă echipamentele diferă, sistemul de fişiere din C şi C++ le transformă într-un instrument logic numit flux (stream). Toate fluxurile se comportă la fel. Deoarece fluxurile sunt independente de echipamente, o funcţie care poate să scrie într-un fişier de pe hard poate fi folosită cu aceeaşi sintaxă pentru a scrie la alt dispozitiv. Sistemul de fişiere C/C++ recunoaşte două tipuri de fluxuri: text şi binar. Fluxuri de tip text Un flux de tip text este o secvenţă de caractere. Standardul ANSI C permite (dar nu impune) ca un flux de tip text să fie organizat în linii terminate
cu un caracter de linie nouă. Totuşi, caracterul de linie nouă din ultima linie este opţional, utilizarea sa fiind determinată de modul de implementare a compilatorului (Majoritatea compilatoarelor de C/C++ nu încheie fluxul de tip text cu un caracter de linie nouă. Să mai semnalăm faptul că, într-un flux de tip text pot să apară anumite transformări cerute de mediul de operare gazdă (De exemplu, un caracter de linie nouă poate fi înlocuit cu perechea început de rând-linie nouă. Acesta este motivul pentru care nu există o relaţie biunivocă între caracterele care sunt scrise sau citite şi cele de la echipamentul extern. Fluxuri binare Un flux binar este o secvenţă de octeţi într-o corespondenţă biunivocă cu cei de la echipamentul extern. Fişiere În C/C++ un fişier poate să fie: un fişier de pe disc, tastatura, ecranul monitorului, imprimanta, etc. Un flux se asociază cu un anumit fişier efectuând o operaţie de deschidere. Odată deschis fluxul, este posibil schimbul de date între fişier şi programul utilizator care l-a deschis. De observat faptul, trivial pentru cunoscători, că nu toate fişierele au aceleaşi posibilităţi. De exemplu, un fişier de pe disc poate să admită un acces aleator la datele stocate în el, în timp ce imprimanta nu o poate face. Astfel că, putem concluziona, pentru claritate: Pentru sistemul I/O din C/C++ toate fluxurile sunt la fel dar nu şi fişierele. Dacă fişierul admite cereri de poziţionare, deschiderea fişierului iniţializează pointerul de fişier la o valoare care indică începutul fişierului. Pe măsură ce se fac operaţii de citire/scriere, pointerul de fişier este incrementat corespunzător naturii operaţiei. Un fişier se disociază de un flux în urma operaţiei de închidere. Dacă este închis un fişier deschis în operaţii de scriere, conţinutul buffer-ului fluxului asociat este scris la dispozitivul extern (acest proces se numeşte flushing=golire ). Toate fişierele se închid automat când programul se termină normal. În caz de blocaj sau dacă programul se termină ca urmare a apelului funcţiei abort(), fişierele nu se închid. Cu menţiunea că în fişierul antet stdio.h se găsesc structurile de control de tip FILE, indispensabile pentru lucrul cu fişiere în C, prezentăm, în continuare contextul C++ referitor la sistemul I/O.
Fluxuri C++ relativ la perifericele standard C++ asigură suportul pentru lucrul cu fluxuri standard în fişierul antet iostream.h. În acest fişier antet sunt definite două ierarhii de clase care admit operaţii de I/O. Clasa cu nivelul de abstractizare cel mai înalt se numeşte streambuf şi asigură operaţiile de bază de intrare/ieşire. Ca programatori, nu folosiţi streambuf direct decât dacă veţi deriva propriile clase pentru efectuarea operaţiilor I/O. A doua ierarhie porneşte cu clasa ios, care acceptă operaţii I/O formatate. Din ea sunt derivate clasele istream, ostream şi iostream. Aceste clase sunt folosite pentru a crea fluxuri capabile să citească, să scrie, respectiv să citească şi să scrie date din/ la echipamentele externe. Clasa ios conţine o serie de alte ramuri relativ la lucrul cu fişiere pe care nu ne propunem să le studiem în cadrul acestei cărţi. ios Stochează variabilele de stare ale unui flux şi tratează erorile
istream Realizează conversia cu format sau fără format a caracterelor dintr-un streambuf
istream_withassign Flux de intrare care defineşte operatorul = permiţând altui flux
sau streambuf să fie atribuit acestui flux
streambuf Implementează un buffer
ostream Realizează conversia cu format sau fără format a datelor dintrun streambuf
iostream Flux bidirecţional atât pentru intrare cât şi pentru ieşire
ostream_withassign Flux de ieşire care defineşte operatorul = permiţând altui flux sau streambuf să fie atribuit acestui flux
iostream_withassign Flux bidirecţional cu operatorul de atribuire = în dotare
Figura 22. Clasele din biblioteca C++ iostream
Se cuvine să observăm (cu referire la Figura 22) că biblioteca iostream este unul dintre exemplele de utilizare a moştenirii multiple în C++. După cum ştim, în C++, o instanţă a unei clase derivate conţine o copie a tuturor membrilor din clasa de bază. De aceea, în cazul unei clase ca iostream, care moşteneşte atât de la istream cât şi de la ostream, fiecare având ca strămoş comun clasa ios, se poate ajunge la două copii ale membrilor din ios. In C++ puteţi evita acest efect prin declararea claselor istream şi ostream ca având pe ios clasă de bază virtuală, astfel: class istream: virtual public ios {…}; class ostream: virtual public ios {…}; În acest fel, ne asigurăm în iostream de o singură copie a resurselor moştenite de la ios. Fluxuri standard în C++ Când îşi începe execuţia un program C++, se deschid automat patru fluxuri predefinite, pe care le prezentăm în tabelul de mai jos. Flux
Semnificaţie
Echipament implicit
cin cout cerr clog
Intrare standard Ieşire standard Ieşire standard pentru eroare Versiune cu memorie tampon pentru cerr
Tastatura Ecran Ecran Ecran
Tabelul 9.1. Fluxurile predefinite C++ Fluxurile cin (consol input), cout (consol output), cerr (consol error) corespund fluxurilor stdin, stdout, stderr din C. Implicit, fluxurile standard sunt folosite pentru a comunica cu consola. Însă, în mediile care admit redirecţionarea I/O, fluxurile standard pot fi redirecţionate spre alte echipamente sau fişiere. I/O formatate în C++ relativ la fluxurile standard Sistemul de I/O din C++ vă permite să formataţi operaţiile I/O, aşa cum se întâmpla şi în cazul utilizării funcţiilor C pentru operaţii I/O, precum: printf, cprintf, scanf,etc. De exemplu, se poate specifica mărimea unui câmp, baza unui număr, numărul de cifre după punctul zecimal,etc. Operatorii din C++ utilizaţi pentru introducerea informaţiilor de formatare sunt >> (operator de extracţie dintr-un flux) şi << (operator de inserţie intr-un flux).
Există două căi înrudite, dar conceptual diferite, prin care se pot formata datele. În primul rând, putem avea acces direct la diferiţi membri ai clasei ios. În al doilea rând, putem folosi funcţii speciale numite manipulatori, care pot fi incluse în operaţiile I/O. Prezentăm, în continuare, modul de utilizare a manipulatorilor de formate, datorită accesibilităţii mai mari a acestora. Manipulatorii standard sunt prezentaţi în tabelul de mai jos. Manipulator
Exemplu de folosire
Efect
dec
cout<<dec<<intvar;
endl
cout<<endl
ends flush hex
cout<<ends cout<<flush cout<<hex<<intvar; cin>>hex>>intvar
oct
cout<<oct<<intvar; cin>>oct>>intvar; cout<<resetioflags (ios::dec);
Converteşte întregi în cifre zecimale; corespunde formatului %d din C Trimite o nouă linie în ostream şi descarcă bufferul Inserează un caracter nul într-un flux Descarcă bufferul fluxului ostream Conversie hexazecimală corespunzătoare formatului %x din ANSI C Conversie octală (formatul %o din C)
resetiosflags(long f)
setbase(int baza)
cout<<setbase(10); cin>>setbase(8);
setfill(int ch)
cout<<setfill(„.‟); cin>>setfill(„ „);
setiosflags(long f)
cout<<setiosflags(ios::dec); cin>> setiosflags(ios::hex); cout<<setprecision(6); cin>>setprecision(10);
setprecision(int p)
setw(int w) ws
cout<<setw(6)<<var; cin>>setw(24)>>buf Cin>>ws;
Reiniţializează biţii de formatare specificaţi de argumentul întreg de tip long Stabileşte baza de conversie la argumentul întreg (trebuie să fie 0,8,10 sau 16). Valoarea 0 este baza implicită. Stabileşte caracterul folosit pentru completarea câmpurilor de mărime specificată Stabileşte biţii de formatare specificaţi de argumentul întreg de tip long Stabileşte precizia conversiei în virgulă mobilă la numărul specificat de zecimale Stabileşte mărimea unui câmp la numărul specificat de caractere Elimină spaţiile libere din fluxul de intrare
Tabelul 9.2. Manipulatori de formatare a operaţiilor I/O în C++ Toţi aceşti manipulatori au prototipul în fişierul antet iomanip.h. Pentru a utiliza manipulatorii setiosflags() şi resetiosflags() trebuie cunoscuţi indicatorii de formatare din tabelul de mai jos.
Nume indicator
Ce efect are utilizarea indicatorului
ios :: skipws ios :: left ios :: right ios :: scientific ios :: fixed ios :: dec ios :: hex ios :: oct ios :: uppercase ios :: showbase
Elimină spaţiile goale din intrare Aliniază ieşirea la stânga în interiorul lăţimii câmpului Aliniază ieşirea la dreapta Foloseşte notaţia ştiinţifică pentru numerele în virgulă mobilă. Foloseşte notaţia zecimală pentru numere în virgulă mobilă Foloseşte notaţia zecimală pentru întregi Foloseşte notaţia hexazecimală pentru întregi Foloseşte notaţia octală pentru întregi Foloseşte litere mari pentru ieşire Indică baza sistemului de numeraţie în cadrul ieşirii (prefixul 0x pentru hexazecimal şi prefixul 0 pentru octal Include un punct zecimal pentru ieşiri în virgulă mobilă Include şi semnul + la afişarea valorilor pozitive Goleşte toate fluxurile după inserarea caracterelor într-un flux
ios :: showpoint ios :: showpos ios :: unitbuf
Tabelul 9.3. Indicatori de formatare a operaţiilor I/O în C++ Notaţia ios::<Nume_indicator> este folosită pentru a identifica indicatorul ca pe un membru al clasei ios. Exemplificăm cele spuse mai sus prin codul C++ de mai jos. Exemplul 9.1 #include<iostream.h> #include<iomanip.h> #include<conio.h> #include<stdio.h> void main() { double nr; clrscr(); gotoxy(10,6); nr=7./3.; gotoxy(5,6); cout<<"Afisare nr. in virg. mobila format implicit..."; gotoxy(10,7); cout<<nr; gotoxy(5,9); cout<<"Afisare nr. in virg. mobila cu precizia specificata..."; gotoxy(10,10); cout<<setprecision(10)<<nr; gotoxy(5,12); cout<<"Afisare nr. in virg. mobila format virgula fixa...";
gotoxy(10,13); cout.setf(ios::fixed); cout<<setprecision(10)<<nr; gotoxy(5,15); cout<<"Afisare nr. in virg. mobila format exponenţial..."; gotoxy(10,16); cout.setf(ios::scientific); cout<<setprecision(10)<<nr; gotoxy(5,18); cout<<"Afisare nr. in virg. mobila format exponential..."; cout<<” cu afisare de semn”; gotoxy(10,19); cout.setf(ios::scientific|ios::showpos); cout<<setprecision(10)<<nr; getch(); } Fluxuri asociate cu fişiere utilizator în C++ Chiar dacă abordarea operaţiilor I/O din C++ formează un sistem integrat, operaţiile cu fişiere (altele decât cele predefinite), sunt suficient de specializate pentru a fi necesar să la discutăm separat. Pentru a efectua operaţii I/O cu fişiere conform paradigmei C++, trebuie să includeţi în programul Dvs. fişierul antet fstream.h. Acesta defineşte mai multe clase, printre care ifstream, ofstream şi fstream. Aceste clase sunt derivate din istream şi, respectiv, din ostream la care ne-am referit şi mai sus. Deschiderea şi închiderea unui fişier Un fişier se deschide, în C++, legându-l de un flux. Înainte de a putea să deschideţi un fişier, trebuie, pentru început, să aveţi un flux. Există trei tipuri de fluxuri: de intrare, de ieşire şi de intrare/ieşire. Pentru a crea un flux de intrare, trebuie să-l declaraţi ca fiind din clasa ifstream. Pentru a crea un flux de ieşire, trebuie să-l declaraţi ca fiind din clasa ofstream. Fluxurile care efectuiază atât operaţii de intrare cât şi operaţii de ieşire, trebuie declarate ca fiind din clasa fstream. Odată declarat fluxul, o modalitate de a-i asocia un fişier extern o reprezintă utilizarea funcţiei open() având prototipul: void open(const char *nume_fisier , int mod, int acces=filebuf::openprot);
În sintaxa de mai sus nume_fisier este un nume extern de fişier, care poate include şi specificarea căii de acces. Valoarea parametrului mod determină modul de deschidere a fişierului. Parametrul mod poate avea una sau mai multe din valorile prezentate în tabelul de mai jos. Operaţie Adaugă date în fişier Când se deschide pentru prima dată, operează poziţionarea în fişier la sfârşitul fişierului (ate înseamnă la sfârşit) Deschide fişierul în mod binar, inhibând interpretarea ios::binary caracterelor <CR> <LF> Deschide fişierul pentru citire ios::in Nu efectuează deschiderea fişierului dacă acesta nu ios::nocreate există deja Dacă fişierul există, încercarea de a-l deschide pentru Ios::noreplace ieşire eşuează, cu excepţia cazului în care ios::app sau ios::ate sunt operate Deschide fişierul pentru scriere Ios::out Trunchiază fişierul dacă el există deja Ios:trunc Tabelul 9.4. Diferite valori ale parametrului care stabileşte modul de deschidere a unui fişier Nume mod ios::app ios::ate
Puteţi specifica mai mult de un mod de lucru pentru un fişier, folosind operatorul pe biţi SAU cu modurile respective. De exemplu, pentru deschiderea unui fişier pentru ieşire şi poziţionarea pointerului la sfârşitul lui se folosesc modurile ios::out şi ios::ate astfel: ofstream oflux(“o_fisier”,ios::out | ios::ate); ceea ce ne arată al doilea procedeu de deschidere a unui fişier, utilizând constructorul clasei ofstream sau, de ce nu, ifstream, dacă este cazul. Pentru a închide un fişier, folosiţi funcţia membru close(). Această funcţie nu preia nici un parametru şi nu returnează nici o valoare. De analizat utilizarea funcţiei close() în exemplele care vor urma. Scrierea şi citirea fişierelor în mod text Sunt două operaţii foarte uşoare, realizate apelând la operatorii >> şi << într-un mod asemănător operaţiilor referitoare la consola sistemului, cu deosebirea că în loc să folosiţi cin şi cout apelaţi la un flux legat de un fişier .
Codul de mai jos arată cum poate fi afişat pe ecranul monitorului conţinutul unui fişier text. Exemplul 9.2 #include <fstream.h> #include <stdlib.h> #include<conio.h> //Functia principala a programului //citeste linia de comanda a programului void main(int argc,char *argv[]) { //Linia de comanda a programului //trebuie sa contina doi parametri if (argc!=2) { cerr<<"Mod de utilizare : lisfis <nume fisier CPP>\n"; exit(0); } // Deschidere fisier text de nume specificat in argv[1] ifstream in(argv[1],ios::in); if (!in) { cerr<<"Fisierul nu poate fi deschis!"; exit(0); } char c; clrscr(); while (in.get(c)) { if (wherey()>20) { gotoxy(20,24); cout<<"Press any key to continue..."; getch(); clrscr(); } cout<<c; }
in.close(); } În cazul în care dorim o copie pe un suport de memorie externă a unui fişier text, procedăm ca în Exemplul 9.3. Exemplul 9.3 #include <iostream.h> #include <stdlib.h> #include <fstream.h> //Copierea unui fisier text void main(int argc, char **argv) { //Buffer-ul de citire char buffer[1]; //Deschiderea fluxului input in mod text //pentru operatii de citire ifstream input(argv[1], ios::in); //Verificare esuare if (input.fail()) { cout << "Eroare deschidere fisier..." << argv[1]; exit(1); } //Deschidere flux output in mod text //pentru operatii de scriere ofstream output(argv[2], ios::out); //Verificare esuare if (output.fail()) { cout << "Error opening the file " << argv[2]; exit(1); } //Copierea efectiva do
{ input.read(buffer, sizeof(buffer)); if (input.good()) output.write(buffer, sizeof(buffer)); } while (!input.eof()); //Inchiderea fluxului input input.close(); //Inchiderea fluxului output output.close(); } Scrierea şi citirea fişierelor în mod binar Există două modalităţi de a scrie şi citi date binare într-un fişier. Prima modalitate se referă la utilizarea funcţiilor get() şi put(). Aceste funcţii sunt orientate pe octeţi, ceea ce înseamnă că get() va citi un octet de date iar put() va scrie un octet de date. Funcţia get() are mai multe forme; o prezentăm, în continuare, împreună cu omoloaga ei put(), pe cea mai des folosită: istream &get(char &ch); ostream &put(char ch); Funcţia get() citeşte un singur caracter din streamul asociat şi memorează valoarea sa în ch. De asemenea, se mai observă că funcţia returnează o referinţă către flux. Funcţia put() scrie ch în flux şi returnează fluxului o referinţă. Un exemplu de utilizare a funcţiei get în Exemplul 9.4. În help-ul kitului cu care lucraţi puteţi găsi informaţii relativ la alte forme ale acestor două funcţii (suprascrieri, de fapt). Exemplul 9.4 #include <iostream.h> #include <fstream.h> int main(int argc, char *argv[]) { char ch; if(argc!=2) {
cout << "Utilizare: DISPFT <nume_fisier>" << endl; return 1; } ifstream in(argv[1], ios::in | ios::binary); if(!in) { cout << "Cannot open file."; return 1; } while(in) { in.get(ch); cout << ch; } } A doua modalitate de a citi şi scrie blocuri de date în binar este folosirea funcţiilor din C++ read() şi write(). Prototipurile lor sunt: istream &read(unsigned char *buf, int numar); ostream &write(const unsigned char *buf, int numar); Funcţia read() citeşte numar octeţi din fluxul asociat şi îl pune în buffer-ul indicat de buf . Funcţia write() scrie în fluxul asociat numar octeţi citiţi din buffer-ul spre care indică buf. Modul de utilizare a acestor două funcţii în contextul unor fluxuri binare se poate urmări în Exemplul 9.5. Exemplul 9.5 #include <iostream.h> #include <stdlib.h> #include <fstream.h> //Exemplu de copiere binara a unui fisier //Copierea se face octet cu octet void main(int argc, char **argv) { //Buffer-ul utilizator char buffer[1]; //Declarare flux input si deschidere in citire binara
ifstream input(argv[1], ios::in | ios::binary); //Verificare terminare operatie de deschidere flux if (input.fail()) { cout << "Eroare deschidere fisier..." << argv[1]; exit(1); } //Deschidere flux output in operatii de scriere binara ofstream output(argv[2], ios::out | ios::binary); //Verificare terminare operatie de deschidere flux if (output.fail()) { cout << "Eroare deschidere fisier..." << argv[2]; exit(1); } //Copierea propriu zisa do { input.read(buffer, sizeof(buffer)); if (input.good()) output.write(buffer, sizeof(buffer)); } while (! input.eof()); input.close(); output.close(); } Detectarea EOF Puteţi să detectaţi sfârşitul fişierului folosind funcţia membru eof() ,care are acest prototip: int eof(); Ea returnează o valoare nenulă când a fost atins sfârşitul fişierului; altfel, returnează zero. Utilizarea funcţiei eof() şi alte elemente legate de lucrul cu fişiere, prezentăm în codul de mai jos, care realizează afişarea conţinutului
unui fişier atât în hexazecimal cât şi în cod ASCII, atunci când codul asociat este printabil. Exemplul 9.6 #include <fstream.h> #include <ctype.h> #include <iomanip.h> #include <stdlib.h> #include<conio.h> void main(int argc,char *argv[]) { if (argc!=2) { cerr<<"Mod de utilizare : lishex <nume fisier CPP>\n"; exit(0); } ifstream in(argv[1],ios::in|ios::binary); if (!in) { cerr<<"Fisierul nu poate fi deschis!"; exit(0); } register int i,j; int count=0; char c[16]; cout.setf(ios::uppercase); clrscr(); while(!in.eof()) { for(i=0;i<16 && !in.eof();i++) { in.get(c[i]); } if (i<16) i--; for(j=0;j<i;j++) cout<<setw(3)<<hex<<(int) c[j]; for(;j<16;j++) cout<<"\t"; for(j=0;j<i;j++) if(isprint(c[j])) cout<<c[j]; else cout<<"."; cout<<endl;
count++; if(count==16) { count=0; cout<<"Press ENTER to continue!"; getch(); clrscr(); cout<<endl; } } in.close(); } Fluxuri asociate cu fişiere având structura definită de utilizator Pentru a simula, în C++, lucrul cu fişiere cu tip, uzual în limbaje precum Pascal, Object Pascal sau în SGBD-uri, calea de urmat este arătată în Exemplul 9.7. Exemplul 9.7 #include <iostream.h> #include <fstream.h> #include <string.h> #include <stdlib.h> #include <conio.h> //Declarare structura inregistrare struct cont { char nume[40]; float sold; unsigned long numar_cont; }; void main(void) { //Instantiere structura struct cont c; //Setare campuri inregistrare strcpy(c.nume, "Pavaloaie Matei"); c.sold = 2500000;
c.numar_cont = 98765432; //Deschide flux binar in opeartii de scriere ofstream outsol("sold.nsn", ios::out | ios::binary); if(!outsol) { cout << "Nu se poate deschide fisier in scriere." << endl; exit (1); } //Scrie inregistrare in flux outsol.write((unsigned char *) &c, sizeof(struct cont)); //Inchide flux outsol.close(); //Deschide flux binar in citire ifstream insol("sold.nsn", ios::in | ios::binary); if(!insol) { cout << "Nu se poate deschide fisierul in citire" << endl; exit (1); } //Citeste inregistrare din flux insol.read((unsigned char*) &c, sizeof(struct cont)); clrscr(); //Afisare formatata campuri pe ecran cout << c.nume << endl; cout << "Numar de cont: " << c.numar_cont << endl; cout.precision(2); cout.setf(ios::fixed); cout << "Sold (LEI):" << c.sold << endl; insol.close(); getch(); } Accesul aleator în fişiere În sistemul de I/O din C++, accesul aleator se efectuează folosind funcţiile seekg() şi seekp(). Formele lor cele mai uzuale sunt:
istream &seekg(streamoff offset, seek_dir origine); ostream &seekp(streamoff offset, seek_dir origine); unde streamoff este un tip definit în iostream.h, capabil să conţină cea mai mare valoare validă pe care o poate avea offset, iar seek_dir este o enumerare care are una din valorile: ios::beg ios::cur ios::end Sistemul de I/O din C++ operează cu doi pointeri asociaţi unui fişier. Unul este pointerul de get , care specifică unde va apărea următoarea operaţie de intrare în fişier. Celălalt este pointerul de put şi specifică unde va avea loc următoarea operaţie de ieşire. După fiecare operaţie de intrare sau ieşire, pointerul corespunzător este avansat automat, secvenţial. Dar, folosirea funcţiilor seekg() şi seekp() permite un acces nesecvenţial la datele din fişier. Funcţiile seekg() şi seekp() deplasează pointerul de înregistrare corespunzător cu offset octeţi faţă de origine. C++ pune la dispoziţie şi două metode care permit determinarea poziţiei curente dintr-un flux: tellp() şi tellg(). Cu menţiunea că lucrul cu fluxuri, în C++, are nenumărate alte faţete pentru a căror prezentare nu dispunem de timpul şi spaţiul necesar, încheiem această scurtă excursie în problematica fişierelor. În fine, pentru curioşi reamintim şi faptul că, însuşi bătrânul C are propria filozofie, extrem de puternică, în ceea ce priveşte lucrul cu fluxuri. Fluxurile de tip şir de caractere Fluxurile pot fi folosite şi pentru operaţii asupra şirurilor de caractere, cu rezultate asemănătoare celor prezentate în Exemplul 9.8. Încă odată, în Exemplul 9.8 se observă importanţă practică a caracterului unitar al abordării operaţiilor I/O în C++. Exemplul 9.8 //Aplicatie la fluxuri de siruri de caractere #include <iostream.h> #include <strstrea.h> #include <conio.h> #include <string.h> void main(void) {
//Sirul de caractere care va fi asociat cu un flux char in_string[] = "Ionescu Valentin 1200000 Zizinului 14"; //Deschidere flux de siruri de caractere istrstream ins(in_string); char nume[20]; char prenume[20]; float salar; char strada[30]; int nr; clrscr(); //Extragere de date din fluxul asociat cu sirul de caractere //si afisare formatata pe ecran ins >> nume; ins >> prenume; cout.setf(ios::left); cout.width(15); cout <<"Nume angajat:"; cout.width(30); strcat(nume," "); strcat(nume,prenume); cout<<nume<< endl; cout.width(15); ins >> salar; ins >> strada; ins >> nr; strcat(strada," nr."); cout.setf(ios::fixed); cout.width(15); cout<<"Strada:"<<strada<<" "<<nr<<endl; cout.width(15); cout<<"Salariu: "<<salar<<endl; getch(); } Supraîncărcarea operatorilor << şi >> relativ la fluxurile standard Operatorii de inserţie (<<), respectiv, extracţie (>>) pot fi instruiţi să lucreze şi asupra altor tipuri de date decât cele fundamentale. În speţă, este vorba de faptul că parametrii lor pot fi obiecte utilizator, obiecte a căror clasă
definitoare supraîncarcă, prin una din metodele cunoscute, aceşti operatori. Astfel că devine perfect plauzibil codul prezentat în Exemplul 9.9. Exemplul 9.9 #include <iostream.h> #include <string.h> #include <conio.h> class Angajat { public: Angajat(void) {}; Angajat(char *name, char sex, int age, char *phone) { strcpy(Angajat::name, name); Angajat::sex = sex; Angajat::age = age; strcpy(Angajat::phone, phone); }; friend ostream &operator<<(ostream &cout, Angajat ang); friend istream &operator>>(istream &stream, Angajat &ang); private: char name[40]; char phone[20]; int age; char sex; }; ostream &operator<<(ostream &cout, Angajat ang) { cout << "Nume: " << ang.name <<endl; cout<< "Sex: " << ang.sex<<endl; cout << "Varsta: " << ang.age <<endl; cout<< "Telefon: " << ang.phone << endl; return cout; } istream &operator>>(istream &stream, Angajat &ang) { cout << "Nume : "; stream >> ang.name; cout << "Sex: ";
stream >> ang.sex; cout << "Varsta: "; stream >> ang.age; cout << "Telefon: "; stream >> ang.phone; return stream; } void main(void) { Angajat persoana; clrscr(); cout<<"Preluare date...."<<endl; cin >> persoana; getch(); clrscr(); cout<<"Afisare date...."<<endl; cout << persoana; getch(); } Supraîncărcarea operatorilor << şi >> relativ la operaţiile cu fişiere De la cele prezentate în Exemplul 9.9 şi până la a spune că obiectele pot fi scrise în fişiere şi, respectiv, citite din fişiere, nu mai este decât un pas. Prin urmare, o modalitate de a asigura persistenţa colecţiilor de obiecte este să redefinim operatorii de inserţie şi extracţie astfel încât aceştia să ştie să lucreze în contextul obiectelor respective. Mai mult, putem să ne imaginăm colecţii heterogene de obiecte păstrate în fişiere, cu condiţia să avem strategie de parcurgere a fişierului respectiv, într-o astfel de ipoteză. Exemplul 9.10 ilustrează supraîncărcarea operatorilor << şi >> astfel ca aceştia să lucreze în contextul unui flux asociat cu un fişier a cărui înregistrare are structură dată. Exemplul 9.10 #include <iostream.h> #include <string.h> #include <fstream.h> #include <conio.h> #include <ctype.h> //Clasa care supraincarca operatorii << si >> class Agenda {
public: char nume[40]; char numar[6]; Agenda(){}; Agenda(char *n,char *nr) { strcpy(nume,n); strcpy(numar,nr); }; //Metode friend care supraincarca operatorii << si >> friend ostream &operator<<(ostream &stream,Agenda a); friend istream &operator>>(istream &stream,Agenda &a); }; //Implementare supraincarcare operator << ostream &operator<<(ostream &stream,Agenda a) { stream<<a.nume; stream<<" "; stream<<a.numar<<endl; return stream; }; //Implementare supraincarcare operator >> istream &operator>>(istream &stream,Agenda &a) { stream>>a.nume; stream>>a.numar; return stream; }; void main() { Agenda ag; char car; ofstream oat("telefon",ios::out); clrscr(); do { cout<<"Nume :"; cin>>ag.nume;
cout<<endl<<"Numar:"; cin>>ag.numar; cout<<endl; oat<<ag; cout<<"Continuati(D,N):"<<endl; } while(toupper(getch())!='N'); oat.close(); ifstream iat("telefon",ios::in); clrscr(); while(!iat.eof()) { strcpy(ag.nume,""); strcpy(ag.numar,""); iat>>ag; cout<<ag.nume<<" "; cout<<ag.numar<<endl; }; getch(); }; Problema asigurării persistenţei colecţiilor de obiecte nu este, întotdeauna, atât de simplă cum rezultă din cele spuse mai sus. O astfel de situaţie este cea în care obiectele agregă recursiv alte obiecte, fapt care complică problema asigurării persistenţei, atât sub aspectul “scriere obiecte” cât şi sub aspectul “citire obiecte”. Acest lucru se va sublinia în continuare.
Capitolul 10 Fluxuri obiect orientate Ĺ&#x;i serializare ĂŽn Java
10.1 Scurtă introducere Deşi mai tânăr decât C++, Java a acumulat deja o experienţă apreciabilă în ceea ce priveşte rezolvarea problemei persistenţei datelor. El propune mai multe ierarhii de clase, care pun în valoare conceptul, deja clasic, de flux şi propune şi elemente suport pentru serializarea colecţiilor de obiecte. La fel ca în C++, stream-urile Java oferă posibiliatea tratării unitare a interfeţelor de comunicare între entităţile unui sistem informatic, fie ele entităţi soft sau hard. Un stream este un canal de comunicaţie generalizat, definit în mod unic prin “capetele” sale: sursa şi destinaţia. De cele mai multe ori, unul din capete este chiar programul în care se declară stream-ul. Şi în Java, există două tipuri fundamentale de stream-uri: input stream-urile, utilizate pentru citirea datelor din diferite surse şi output stream-urile, utilizate pentru scrierea datelor în diferite destinaţii. Mai putem observa şi alte asemănări între perspectiva Java şi perspectiva C++, în ceea ce priveşte persistenţa: există fluxuri standard şi alte fluxuri decât cele standard (relativ la fişiere, relativ la şiruri de caractere, relativ la buffe-re de octeţi), există filtre de diferite tipuri. Programatorul care vrea să înveţe să lucreze eficient cu fluxurile în Java, se izbeşte de o situaţie oarecum asemănătoare celei din C++, dacă nu cumva mai rea: Instrumentele puse la dispoziţie de Sun sunt extrem de diversificate şi se promovează chiar filozofii diferite de lucru cu fluxurile, datorită faptului că prima ierarhie de clase care fundamenta lucrul cu fluxuri era orientată pe 8 biţi (două ierarhii având drept clase rădăcină clasele InputStream şi OutputStream) iar din raţiuni de implementare a conceptului de Internationalization s-a dezvoltat o soluţie alternativă care este orientată pe 16 biţi (două ierarhii având drept clase rădăcină clasele Reader şi Writer)13. Astfel că, programatorul se confruntă cu două ierarhii de clase, între care există destule asemănări pentru a nu dispera cu totul dar şi destule deosebiri pentru a nu putea renunţa la nici una dintre ele deocamdată. Cert este că soluţia Java pentru lucrul cu fluxuri este puternic orientată pe obiecte, ca soluţie tehnică. În sfârşit, să mai precizăm faptul că oferta C++ pentru salvarearestaurarea obiectelor îşi găseşte în Java un răspuns mai îndrăzneţ, sub forma serializării. Pentru mai multe detalii relativ la structura acestor ierarhii se poate consulta Călin Marin Văduva, Programarea în Java, Editura Albastră, Cluj-Napoca, 2001 13
Despre toate acestea în cele ce urmează.
10.2 Stream-uri standard în Java Java pune la dispoziţia utilizatorului, în ideea comunicării cu consola, trei stream-uri standard: Standard Input Standard Output StandardError. Stream-ul Standard Input este utilizat pentru preluarea datelor, în timp ce celelalte două sunt utilizate pentru afişarea datelor şi a mesajelor de eroare. Implicit, Standard Input preia datele de la tastatură iar celelalte două afişează datele la monitor. Unul dintre avantajele utilizării stream-urilor, în comunicarea cu utilizatorul, îl reprezintă şi posibilitatea de a redirecta streamurile standard spre alte periferice. În Java, toate stream-urile standard sunt accesate prin clasa System: pentru Standard Input avem System.in, pentru Standard Output avem System.out, pentru Standard Error avem System.err. System.in este un membru static al clasei System şi este de tipul InputStream, o clasă abstractă din pachetul java.io. O parte dintre funcţiile clasei InputStream şi aspecte relativ la redirectare în cele ce urmează. Funcţii de citire şi de control al poziţiei la citire: public abstract int read() throws IOException public int read(byte b[]) throws IOException public int read(byte b[], int off, int len) throws IOException public long skip(long n) throws IOException Funcţii de repetare citire, funcţii de gestiune buffer: public synchronized void mark (int readlimit) public synchronized void reset() throws IOException public boolean markSuported() Funcţii de informare: public int available() throws IOException
Funcţia de închidere stream: public void close() throws IOException. De fapt, aceste metode ale clasei InputStream prefigurează elementele fundamentale ale strategiei Java de lucru cu fluxurile. Funcţia read(), fără nici un parametru, citeşte octetul curent din stream şi îl returnează sub forma unui întreg între 0 şi 255. Dacă s-a ajuns la capătul stream-ului, se returnează valoarea -1. Funcţiile read, având ca parametru un tablou de octeţi, citesc de la poziţia curentă din stream un număr de octeţi egal cu len sau cu lungimea tabloului b şi îl încarcă în tabloul b, la poziţia off dacă aceasta este specificată. Ele returnează numărul de octeţi citiţi în buffer-ul b sau -1 dacă s-a ajuns la capătul stream-ului. Funcţia skip este utilizată pentru a muta poziţia citirii peste un anumit număr de octeţi. Toate aceste metode blochează firul de execuţie în care ne aflăm, până când toate datele care se cer sunt disponibile, s-a ajuns la sfârşitul stream-ului sau s-a aruncat o excepţie. Redirectarea stream-urilor standard se poate realiza cu ajutorul următoarelor trei funcţii, disponibile în clasa System: public static void setIn(InputStream in) public static void setOut(PrintStream out) public static void setErr(PrintStream err) În Exemplul 10.1 sunt arătate elementele de protocol fundamentale pentru lucrul cu stream-uri în Java, cu referire la stream-urile standard. Este vorba despre următoarele elemente invariabile: Asocierea fluxului cu un fişier, echipament standard sau altă structură de date. Efectuarea de operaţii de tipul citire sau scriere de date. Poziţionarea în flux, când acest lucru este posibil Închiderea fluxului Aşa cum se va vedea şi în exemplele care vor urma şi cum, de altfel, era previzibil din signatura metodelor pe care le-am anunţat ca făcând parte din structura clasei InputStream, tratarea excepţiilor în cazul operaţiilor I/O este imperativă. În Exemplul 10.2 se arată cadrul Java pentru redirectarea stream-urilor standard.
Exemplul 10.1 //Utilizare stream-uri standard //Acestea sunt asociate implicit cu echpamentele periferice //Tastatura â&#x20AC;&#x201C; Sistem.in //Ecranul monitorului â&#x20AC;&#x201C; Sistem.out / Sistem.err import java.io.*; import java.util.*; public class IO1 { public static void main(String[] s) { boolean exit=false; System.out.println("Incerc IO\n "+ " Informatii despre sistem"); while(!exit) { System.out.println("Optiuni...."); System.out.println("\t (D) Data"); System.out.println("\t (P) Proprieteti sistem"); System.out.println("\t (T) Terminare"); try { char readChar=(char)System.in.read(); int avlb=System.in.available(); System.in.skip(avlb); switch(readChar) { case 'D': case 'd': System.out.println("Data:"+ new Date().toString()); break; case 'P': case 'p': Properties prop=System.getProperties(); prop.list(System.out); break; case 'T': case 't': System.out.println("La revedere..."); exit=true; break; } } catch(IOException e)
{ System.err.println(e.getMessage()); } } } } Exemplul 10.2 // ExemplificÄ&#x192; redirectarea stream-urilor standard import java.io.*; public class Redirect { // Arunca exceptii IOException la consola public static void main(String[] args) throws IOException { //Flux de intrare cu buffer asociat cu fisierul //text care contine programul BufferedInputStream in = new BufferedInputStream( new FileInputStream("Redirect.java")); //Filtru asociat cu fluxul definit mai sus PrintStream out =new PrintStream( new BufferedOutputStream( new FileOutputStream("test.out"))); //Redirectare fluxuri standard System.setIn(in); System.setOut(out); System.setErr(out); //Filtrarea stream-ului standard cu ajutorul clasei //BufferedReadre pentru a permite utilizarea metodei readLine() //versiune ne-deprecated. //Deschidere flux BufferedReader br = new BufferedReader(new InputStreamReader (System.in)); String s; //Citire flux pana la terminare while((s = br.readLine()) != null) System.out.println(s);
//Inchidere flux out.close(); } }
10.3 Clasa File în lucrul cu stream-uri Clasa File, din biblioteca I/O Java, furnizează o abstractizare independentă de platformă pentru obţinerea informaţiilor despre fişiere, ca de exemplu: numele de cale, dimensiunea fişierului, data modificării, etc. Pentru a obţine astfel de informaţii despre fişier trebuie ca, mai întâi, să creaţi un obiect File utilizând unul din constructorii de mai jos: File (String cale); File (String cale, String nume); File (File dir, String nume); Parametrul cale din prima versiune de constructor conţine calea către fişier, în timp ce acelaşi parametru, din cea de-a doua versiune, conţine calea directorului. Paramerul nume specifică numele fişierului. Parametrul dir, din ce-a de-a treia versiune permite utilizarea unui alt obiect File, ca director. Utilitatea clasei File poate fi desprinsă, ca un început, şi din Exemplul 10.3 şi Exemplul 10.4. Exemplul 10.3 //Listarea tuturor fişierelor din directorul curent import java.io.*; public class TestFile { public static void main(String[] sir) { File dc=new File("."); String listaf[]=dc.list(); for(int i=0;i<listaf.length;i++) { if(i % 23==0) { try { System.in.read(); System.in.read(); } catch(IOException e)
{} }; System.out.println(listaf[i]); } } } Exemplul 10.4 //Listarea tuturor fisierelor din directorul curent //avand o extensie data import java.io.*; class JavaFileFilter implements FilenameFilter { public boolean accept(File dir, String nume) { return nume.endsWith(".java"); } } public class FiltruF { public static void main(String[] sir) { File dc=new File("."); String listaf[]=dc.list(new JavaFileFilter()); for(int i=0;i<listaf.length;i++) { if(i % 23==0) { try { System.in.read(); System.in.read(); } catch(IOException e) {} }; System.out.println(listaf[i]); } } }
10.4 Citirea datelor dintr-un stream Aşa cum, probabil că s-a înţeles, există două grupuri mari de stream-uri, în funcţie de obiectivul lor: scrierea sau citirea datelor. Pentru citirea datelor dintr-un flux avem clasele derivate din clasele abstracte InputStream sau Reader. Amândouă aceste clase sunt clase abstracte care furnizează metode care permit operaţii asemănătoare celor pe care le-am prezentat deja în discuţia referitoare la stream-urile standard. Referindu-ne la InputStream, fiind o clasă abstractă nu poate fi utilizată în instanţierea unui obiect stream. Pentru crearea obiectelor de tip stream, pornind de la clasa InputStream, s-au derivat mai multe clase. Aceste clase le-am putea împărţi, la rândul lor, în două grupuri importante: clase stream conectate la diferite tipuri de surse; clase stream care se conectează la cele de mai sus, adăugând noi operaţii şi funcţionând ca “filtre” aplicate operaţiilor de citire. Clasele din prima categorie sunt derivate direct din clasa InputStream. Pentru a putea utiliza efectiv interfaţa anunţată de clasa InputStream a fost nevoie de construirea unor clase derivate din aceasta, clase care să poată fi conectate la diferite tipuri de surse reale. Dintre aceste clase remarcăm ca fiind cel mai mult folosite: ByteArrayInputStream Este o clasă care permite conectarea unui stream la un tablou de octeţi. Operaţiile de citire din stream vor permite citirea datelor din tabloul de octeţi, gestiunea operaţiilor fiind asumată de către instanţa stream. StringBufferInputStream Permite conectarea unui stream la un şir de caractere. Această clasă este considerată deprecated, recomandându-se utilizarea clasei StringReader. FileInputStream Este una dintre cele mai utilizate clase de tip stream şi ne oferă posibilitatea conectării cu un fişier pentru a citi datele înregistrate în acesta. După cum se poate vedea, la analiza atentă a definiţiei clasei FileInputStream, aceasta conţine mai multe versiuni de constructori, care permit asocierea stream-ului cu un fişier în diferite moduri: numele specificat ca o variabilă sau constantă String, numele specificat ca o variabilă File, numele specificat ca o variabilă FileDescriptor.
O categorie importantă de clase derivate din InputStream o formează clasele de tip “filtru”, derivate din clasa FilterInputStream, la rândul ei, derivată din clasa InputStream. Dintre clasele din această categorie se cuvine să remarcăm câteva utilizate intens: DataInputStream Este una dintre cele mai utilizate clase dintre cele de tip filtru. Această clasă conţine mai multe funcţii, care permit citirea unor tipuri fundamentale de date (int, float, double, char, etc) într-un mod independent de maşină. De regulă, această clasă este utilizată împreună cu clasa DataOutputStream, clasă care are operaţii de scriere în stream, orientate pe tipurile fundamentale. Împreună, aceste două clase, oferă o soluţie elegantă la problema gestiunii fişierelor a căror înregistrare are structura definită de utilizator. Este momentul să remarcăm că, în principiu, în Java, la fel ca în C++, putem avea fluxuri de octeţi, fluxuri de caractere şi fluxuri de date cu structură cunoscută. Revenind la clasa DataInputStream, prezentăm, în continuare, câteva dintre metodele mai mult folosite. Metoda boolean readBoolean() byte readByte Int readUnsignedByte() short readShort() char readChar() int readInt() long readLong() float readFloat() double readDouble() String readLine() String readUTF()
Rolul Citeşte o dată booleană Citeşte un octet Citeşte un octet unsigned Citeşte un short (16 biţi) Citeşte un caracter Unicode Citeşte un întreg pe 32 biţi Citeşte un long pe 64 biţi Citeşte un număr real în virgulă mobilă simplă precizie Citeşte un număr real în virgulă mobilă dublă precizie Citeşte o linie Citeşte un şir de caractere în format UTF (Unicode Text Format) Tabelul 10.1. Metode ale clasei DataInputStream
Dintre clasele de tip filtru merită să mai remarcăm şi clase precum: BufferedInputStream, LineNumberInputStream, ZipInputStream, etc.
10.5 Scrierea datelor într-un stream Pentru scrierea datelor într-un stream avem clasele derivate din clasele abstracte OutputStream sau Writer. Amândouă aceste clase sunt clase abstracte, care furnizează metode care permit operaţii de scriere a datelor în stream-uri, complementare celor de citire, ca funcţionalitate. Referindu-ne la clasa OutputStream, fiind o clasă abstractă nu poate fi utilizată în instanţierea unui obiect stream. Totuşi, ea este o ocazie de a specifica o interfaţă general valabilă în operaţiile de scriere în fluxuri, având următoarea definiţie: public abstract class OutputStream { public abstract void write(int b) throws IOException public void write(byte b[] ) throws IOException public void write(byre[], int off, int len) throws IOException public void flush()throws IOException public void close()throws IOException } Pentru crearea obiectelor de tip stream, pornind de la clasa OutputStream, s-au derivat mai multe clase. Aceste clase le-am putea împărţi, la rândul lor, în două grupuri importante: clase stream conectate la o destinaţie; clase stream care se conectează la cele de mai sus, adăugând noi operaţii şi funcţionând ca “filtre” aplicate operaţiilor de scriere. Clasele din prima categorie sunt derivate direct din clasa OutputStream. Pentru a putea utiliza efectiv interfaţa anunţată de clasa OutputStream a fost nevoie de construirea unor clase derivate din aceasta, clase care să poată fi conectate la diferite tipuri de destinaţii reale. Dintre aceste clase remarcăm ca fiind cel mai mult folosite: ByteArrayOutputStream Este o clasă care permite conectarea unui stream la un tablou de octeţi. Operaţiile de scriere în stream vor permite adăugare de date în tabloul de octeţi, gestiunea operaţiilor fiind asumată de către instanţa stream.
FileOutputStream Este clasa pereche a clasei FileInputStream, dintre cele mai utilizate clase de tip stream şi ne oferă posibilitatea conectării cu un fişier pentru a scrie date în acesta. După cum se poate vedea, la analiza atentă a definiţiei clasei FileOutputStream, aceasta conţine mai multe versiuni de constructori, care permit asocierea stream-ului cu un fişier în diferite moduri: numele specificat ca o variabilă sau constantă String, numele specificat ca o variabilă File, numele specificat ca o variabilă FileDescriptor. O categorie importantă de clase derivate din OutputStream o formează clasele de tip “filtru”, derivate din clasa FilterOutputStream, la rândul ei, derivată din clasa OutputStream. Dintre clasele din această categorie se cuvine să remarcăm câteva utilizate intens: DataOutputStream Este una dintre cele mai utilizate clase dintre cele de tip filtru. Această clasă conţine mai multe funcţii care permit citirea unor tipuri fundamentale de date (int, float, double, char, etc) într-un mod independent de maşină. De regulă, această clasă este utilizată împreună cu clasa DataInputStream, clasă care are operaţii de citire în stream, orientate pe tipurile fundamentale. Împreună, aceste două clase oferă o soluţie elegantă la problema gestiunii fişierelor, a căror înregistrare are structura definită de utilizator. Clasa DataOutputStream, are o serie de metode folosite, după caz, la realizarea operaţiilor de scriere în stream-uri. Metoda void writeBoolean(boolean v) void writeByte(int v) void writeBytes(String s) void writeShort(int v) void writeChar(int v) void writeInt(int v) void writeLong(long v) void writeFloat(float v)
Rolul Scrie o dată booleană Scrie un octet Scrie un şir de caractere ca o secvenţă de octeţi Scrie un short (16 biţi) Scrie un caracter Unicode Scrie un întreg pe 32 biţi Scrie un long pe 64 biţi Scrie un număr real în virgulă mobilă simplă precizie
Scrie un număr real în virgulă mobilă dublă precizie void writeChars(String s) Scrie un şir de caractere ca o secvenţă de 16 biţi void writeUTF(String S) Scrie un şir de caractere în format UTF (Unicode Text Format) Tabelul 10.2. Metode ale clasei DataOutputStream void writeDouble(double v)
Dintre clasele de tip filtru merită să mai remarcăm şi clase precum: BufferedOutputStream, PrintStream, ZipOutputStream, etc. Relativ la lucrul cu fişiere, un rol important îl joacă clasa RandomAccessFile, care nu este subclasă nici a clasei InputStream, nici a clasei OutputStream. Însă, cu ajutorul instanţelor ei, puteţi efectua în acelaşi timp atât operaţii de scriere cât şi de citire. În plus, după cum arată şi numele, un obiect RandomAccessFile furnizează acces aleator la datele dintr-un fişier, ceea ce instanţele descendenţilor claselor InputStream sau OutputStream nu pot. Pentru compatibilitate, la utilizare, cu clasele DataInputStream şi DataOutputStream, clasa RandomAccessFile implementează interfeţele DataOutput şi DataInput, interfeţe pe care le implementează şi clasele DataInputStream şi DataOutputStream. O discuţie asemănătoare se poate purta relativ la ierarhiile de clase ale căror rădăcini sunt clasele Reader şi Writer, iearhii care implementează alternativa I/O Java pe 16 biţi. Funcţionalitatea lor, însă, nu elimină cu totul utilitatea ierarhiilor pe care le-am prezentat mai sus, pe scurt. Înţelegerea exactă a modului de lucru cu oricare dintre ierarhiile menţionate mai sus poate fi realizată consultând documentaţia aferentă kit-urilor jdk1.o sau jdk1.1. “Jungla” protocoalelor de lucru cu stream-uri în Java este, după cum se vede, mult mai diversificată decât oferta C++. Programatorul din lumea reală trebuie să se acomodeze cu elementele fundamentale relativ la stream-urile Java, rămânând ca în situaţii excepţionale să înveţe utilizarea unor procedee excepţionale de manevrare a stream-urilor. Exemplele care urmează încearcă să evidenţieze elemente de protocol socotite uzuale în lucrul cu stream-uri în Java. Exemplul 10.5 //Situatii tipice de utilizarea fluxurilor in Java import java.io.*; public class IOStreamDemo
{ // Metoda ridica exceptii la consola public static void main(String[] args) throws IOException { //1a. Citirea orientata pe linii intr-un fisier text BufferedReader in = new BufferedReader( new FileReader("IOStreamDemo.java")); String scit; String sImRAM = new String(); //s2 pastreaza continutul fisierului IOStreamDemo.java //ca imagine RAM while((scit = in.readLine())!= null) sImRAM += scit + "\n"; in.close(); // 1b. Citire de la tastatura: BufferedReader stdin =new BufferedReader( new InputStreamReader(System.in)); System.out.print("Enter a line:"); System.out.println(stdin.readLine()); System.in.read(); // 2. Citire din memorie //Se va folosi sImRAM, creat la 1a StringReader in2 = new StringReader(sImRAM); int c; //Afisare imagine memorie a continutului //fisierului IOStreamDemo.java while((c = in2.read()) != -1) System.out.print((char)c); System.in.read(); System.in.read(); // 3. Preluare date formatate in memorie //Din nou se apeleaza la imaginea memorie a //fisierului IOStreamDemo.java try {
DataInputStream in3 =new DataInputStream( new ByteArrayInputStream(sImRAM.getBytes())); while(true) System.out.print((char)in3.readByte()); } catch(EOFException e) { System.err.println("End of stream"); } System.in.read(); System.in.read(); // 4. Creare fisier format output try { BufferedReader in4 =new BufferedReader( new StringReader(sImRAM)); PrintWriter out1 =new PrintWriter( new BufferedWriter( new FileWriter("IODemo.out"))); int lineCount = 1; while((scit = in4.readLine()) != null ) out1.println(lineCount++ + ":" + scit); out1.close(); } catch(EOFException e) { System.err.println("End of stream"); } String sir; BufferedReader inper = new BufferedReader( new FileReader("IODemo.out")); System.out.println("################################"); while((sir = inper.readLine())!= null) System.out.println(sir); inper.close(); System.in.read(); System.in.read(); // 5. Salvare si consultare date cu tip try
{ DataOutputStream out2 =new DataOutputStream( new BufferedOutputStream( new FileOutputStream("Data.txt"))); out2.writeDouble(3.14159); out2.writeBytes("Acesta este numarul PI\n"); out2.writeDouble(1.41413); out2.writeUTF("Radacina patrata a lui 2"); out2.close(); DataInputStream in5 =new DataInputStream( new BufferedInputStream( new FileInputStream("Data.txt"))); BufferedReader in5br =new BufferedReader( new InputStreamReader(in5)); // Trebuie sa folositi DataInputStream pentru date: System.out.println(in5.readDouble()); // Numai metoda readUTF() va recupera // sirul Java-UTF corect: // Cu readLine() se citesc corect date // scrise cu writeBytes. System.out.println(in5br.readLine()); System.out.println(in5.readDouble()); System.out.println(in5.readUTF()); } catch(EOFException e) { System.err.println("End of stream"); } // 6.Citire/scriere fisier in acces aleator RandomAccessFile rf =new RandomAccessFile("rtest.dat", "rw"); for(int i = 0; i < 10; i++) rf.writeDouble(i*1.414); rf.close(); rf =new RandomAccessFile("rtest.dat", "rw"); rf.seek(5*8); rf.writeDouble(47.0001); rf.close(); rf =new RandomAccessFile("rtest.dat", "r"); for(int i = 0; i < 10; i++)
System.out.println("Value " +i+":"+rf.readDouble()); rf.close(); } } Ca observaţii finale la cele discutate până acum relativ la problema persistenţei datelor în Java, aş menţiona: Puternica orientare pe obiecte a soluţiilor Java la problema persistenţei. Flexibilitatea cu care putem utiliza diferitele varietăţi de fluxuri (filtrare, redirectare) Obligativitatea tratării excepţiilor I/O în codul Java, ceea ce sporeşte coeficientul de robusteţe al codului Java afectat operaţiilor I/O.
10.6 Serializarea obiectelor Java 1.1 introduce un nou concept, numit serializarea obiectelor. Utilizând serializarea, un obiect poate fi transformat într-o secvenţă de octeţi ( care poate fi transmisă în reţea sau care poate fi stocată într-o specie de memorie), secvenţă care poate fi folosită pentru refacerea completă a obiectului iniţial. Avantajul serializării este evident, prin faptul că simplifică procedura de transmitere a unui obiect între două entităţi ale unui sistem informaţional automatizat. Prin utilizarea serializării, programatorul a scăpat de grija transformării obiectului într-o succesiune de octeţi, de ordonarea lor, de problema diferenţei de reprezentare pe diferite platforme, toate acestea făcându-se automat. În rezumat, putem spune că serializarea introduce un alt nivel de transmitere a datelor, la care unitatea fundamentală de transfer este obiectul. În practică, serializarea obiectelor este impusă de situaţii precum: RMI (Remote Method Invocation) Comunicare de obiecte între aplicaţii aflate pe calculatoare diferite, într-o reţea. Lightweight persistence Posibilitatea stocării unui obiect, ca ansamblu unitar, în vederea utilizării lui în cadrul unei execuţii ulterioare. Tehnologia Java Beans Tehnologie Java de lucru cu componente.
Procedeul Java de serializare/deserializare a unui obiect este simplu, dacă acesta îndeplineşte anumite condiţii. Procedeul Java de serializare/deserializare Serializarea unui obiect este o opereaţie relativ simplă, care implică lucrul cu clasa ObjectOutputStream, care înfăşoară un stream de tipul OutputStream, conectat la o destinaţie. Conexiunea cu un stream OutputStream se face prin intermediul constructorului: public ObjectOutputStream(OutputStream out) throws IOException; Clasa ObjectOutputStream este derivată din clasa OutputStream şi implementează interfaţa ObjectOutput (derivată din DataOutput). Interfaţa ObjectOutput declară metodele specifice serializării unui obiect. Dintre aceste metode, cea mai importantă este metoda writeObject având signatura: public final void writeObject(Object obj) throws IOException Prin intermediul clasei ObjectOutputStream, se pot scrie atât date primitive (cu ajutorul metodelor declarate de interfaţa DataOutput) cât şi obiecte, folosind metoda writeObject. Deserializarea (adică reconstituirea unui obiect dintr-un stream) este, de asemenea, simplă şi implică utilizarea clasei ObjectInputStream. Această clasă înfăşoară un stream de tipul InputStream, stream transmis ca şi parametru în constructorul clasei: public ObjectInputStream(InputStream in) throws IOException, StreamCorruptedException Clasa ObjectInputStream este derivată din clasa InputStream şi implementează interfaţa ObjectInput (derivată din interfaţa DataInput). Interfaţa ObjectInput declară metodele specifice deserializării unui obiect. Dintre acestea, cea mai importantă este metoda readObject, definită astfel: public final Object readObject() throws OptionalDataException, ClassNotFoundException, IOException Excepţia OptionalDataException apare în cazul în care, în locul unui obiect în stream se află un tip de dată primitiv.
Excepţia ClassNotFoundException apare atunci când clasa obiectului din stream nu poate fi găsită, în contextul actual de execuţie. O înţelegere mai bună a serializării / deserializării poate fi obţinută urmărind exemplele de mai jos. Exemplul 10.6 // Exemplificarea serializării obiectelor import java.io.*; class Data implements Serializable { private int i; Data(int x) { i = x; } public String toString() { return Integer.toString(i); } } public class Serializ implements Serializable { //Generare numar aleator intreg private static int r() { return (int)(Math.random() * 10); } private Data[] d = {new Data(r()), new Data(r()), new Data(r())}; private Serializ next; private char c; // Valoarea lui i indică numarul de elemente din lista Serializ(int i, char x) { System.out.println(" Serializ constructor: " + i); c = x; if(--i > 0) next = new Serializ(i, (char)(x + 1)); } Serializ()
{ System.out.println("Constructor implicit"); } public String toString() { String s = ":" + c + "("; for(int i = 0; i < d.length; i++) s += d[i].toString(); s += ")"; if(next != null) s += next.toString(); return s; } // Ridica exceptii la consola public static void main(String[] args) throws ClassNotFoundException, IOException { Serializ w = new Serializ(6, 'a'); System.out.println("w = " + w); ObjectOutputStream out =new ObjectOutputStream( new FileOutputStream("serializ.out")); out.writeObject("Serialize storage"); out.writeObject(w); out.close(); // Also flushes output ObjectInputStream in =new ObjectInputStream( new FileInputStream("serializ.out")); String s = (String)in.readObject(); Serializ w2 = (Serializ)in.readObject(); System.out.println(s + ", w2 = " + w2); ByteArrayOutputStream bout =new ByteArrayOutputStream(); ObjectOutputStream out2 =new ObjectOutputStream(bout); out2.writeObject("Serializ storage"); out2.writeObject(w); out2.flush(); ObjectInputStream in2 =new ObjectInputStream( new ByteArrayInputStream(bout.toByteArray())); s = (String)in2.readObject(); Serializ w3 = (Serializ)in2.readObject(); System.out.println(s + ", w3 = " + w3); }
} Exemplul 10.7 import java.io.*; import java.util.*; class House implements Serializable {} class Animal implements Serializable { String name; House preferredHouse; Animal(String nm, House h) { name = nm; preferredHouse = h; } public String toString() { return name + "[" + super.toString() + "], " + preferredHouse + "\n"; } } public class Serializ1 { public static void main(String[] args) throws IOException, ClassNotFoundException { House house = new House(); ArrayList animale = new ArrayList(); animale.add(new Animal("Grivei -cainele", house)); animale.add(new Animal("Coco papagalul", house)); animale.add(new Animal("Vasile -motanul", house)); System.out.println("animale: " + animale); ByteArrayOutputStream buf1 =new ByteArrayOutputStream(); ObjectOutputStream o1 =new ObjectOutputStream(buf1); o1.writeObject(animale); //Inca odata o1.writeObject(animale);
// Scriem si intr-un stream diferit ByteArrayOutputStream buf2 =new ByteArrayOutputStream(); ObjectOutputStream o2 =new ObjectOutputStream(buf2); o2.writeObject(animale); // Acum le citim ObjectInputStream in1 =new ObjectInputStream( new ByteArrayInputStream(buf1.toByteArray())); ObjectInputStream in2 =new ObjectInputStream( new ByteArrayInputStream(buf2.toByteArray())); ArrayList animale1 =(ArrayList)in1.readObject(); ArrayList animale2 =(ArrayList)in1.readObject(); ArrayList animale3 =(ArrayList)in2.readObject(); System.out.println("animale1: " + animale1); System.out.println("animale2: " + animale2); System.out.println("animale3: " + animale3); } } Exemplele 10.6 şi 10.7 arată, printre altele, şi condiţiile pe care trebuie să le îndeplinească un obiect pe care vrem să îl serializăm. În speţă, este vorba de faptul că pentru ca obiectul să poată fi serializat, clasa lui definitoare trebuie să respecte una din următoarele condiţii: Să implementeze interfaţa Serializable În această situaţie transformările obiect-stream şi stream-obiect se pot face automat. Să implementeze interfaţa Externalizable şi să suprascrie metodele acesteia writeExternal şi readExternal. În acest caz, programatorul este responsabil (prin specificarea metodelor anterior amintite) de transformarea obiectului în secvenţe de octeţi şi invers. Evident, spaţiul de care dispunem nu ne permite să tratăm exhaustiv problemele pe care le pune asigurarea persistenţei datelor în aplicaţiile Java. Scopul acestei cărţi a fost de a realiza o deschidere asupra universului problematic dezvoltat în Java în jurul ideii de persistenţă. Stă în puterea fiecărui cititor în parte să se aplece cu temeinicie asupra aspectelor de detaliu sau , de ce nu, filosofice, neelucidate încă.
Capitolul 11 Programare concurentÄ&#x192; cu suport obiect orientat. Perspectiva Java
11.1 Noţiuni de programare concurentă Programarea concurentă presupune existenţa mai multor sarcini care trebuie să fie executate în paralel, ceea ce implică, într-o formă sau alta, partajarea resurselor comune ale sistemului pe care se derulează execuţia sarcinilor în cauză. Făcând abstracţie de arhitectura hard14 care o susţine, programarea concurentă poate fi materializată prin suport pentru programarea multitasking, suport pentru programarea multifir şi suport pentru programarea distribuită. Atunci când o aplicaţie este implementată pe un sistem multiprocesor, se spune că avem o aplicaţie multiprocesată. În situaţia în care aplicaţia este implementată pe o reţea de calculatoare, spunem că aplicaţia este distribuită. În acest curs nu ne vom interesa de programarea distribuită şi nici de programarea multitasking. Pentru a fi posibilă programarea multitasking, mai întâi sistemul de operare trebuie să fie capabil de execuţia simultană a mai multor programe. În cazul în care aşa ceva este posibil, la nivelul limbajelor de programare ne putem pune probleme de sincronizare a accesului la resursele comune. Din acest punct de vedere, relaţia dintre Java şi Windows, ca sistem de operare, nu este extrem de cordială. În schimb Object Pascal, limbajul de programare în mediul de programare vizuală Delphi, gândit pentru a realiza aplicaţii având ca ţintă platforma Windows, posedă înveliş sintactic specific pentru rezolvarea problemelor de partajare a resurselor critice, în spiritul WIN32API15. Pe de altă parte, programarea distribuită este posibilă, în Java, apelând la tehnologii care susţin corespunzător acest stil de programare(RMI, CORBA, etc.). În această carte ne vom interesa de posibilităţile pe care le oferă Java pentru a face programare multifir. Majoritatea compilatoarelor de C++, legate de maşina MSDOS, nu oferă suport nativ sau înveliş sintactic corespunzător pentru programarea multifir, ci doar rudimente sintactice pentru simularea greoaie a multitasking-ului. În sfârşit, să mai observăm că programarea multifir la care ne referim va fi asociată cu posibilităţile unei maşini monoprocesor, ceea ce înseamnă, iarăşi, că sistemul de operare este “arbitrul” care stabileşte regulile de bază care trebuie urmate pentru ca un fir de execuţie să poată accesa reursele partajabile ale maşinii (îndeosebi timpul UC). Arbitrajul exercitat de sistemul de operare (în relaţia cu timpul UC) se reduce, practic, la acordarea unor cuante de timp UC tuturor firelor de execuţie active la un moment dat, eventual, în funcţie de priorităţile asociate acestora la creare. Toate celelalte probleme care decurg din execuţia simultană a mai multor fire de execuţie sunt de competenţa 14
15
Sistem monoprocesor, sistem multiprocesor, sistem vectorial, sistem distribuit, etc. Interfaţa de Programare a Aplicaţiilor sub sistemul de operare Windows.
programatorilor. Aceste probleme se regăsesc, generic, în sintagma “comunicare şi sincronizare”, pentru care limbajele oferă mijloace a căror întrebuinţare este la latitudinea programatorilor. Să mai observăm că noţiunea de fir de execuţie se referă la o unitate de prelucrare, asociată cu noţiunea de proces, în sensul că fiecare fir de execuţie este găzduit în spaţiul de adrese al unui proces. În fine, trebuie spus că, referindu-ne la Java, funcţia main() a unui program Java este, ea însăşi, un fir de execuţie, care se numeşte firul principal de execuţie. O situaţie asemănătoare apare şi în Object Pascal unde programul principal, aflat în fişierul cu extensia .dpr este asimilat cu noţiune de fir de execuţie principal. Lucrul cu fire de execuţie este o necesitate, în foarte multe situaţii de programare. Dacă, de exemplu, ne gândim la o aplicaţie care simulează calculul tabelar, nu este greu de priceput necesitatea mai multor fire de execuţie: unul care se ocupă de interactivitatea cu utilizatorul, unul care gestionează implicaţiile modificării conţinutului celulei curente asupra conţinutului altor celule, etc. Vom încerca, în continuare, să fixăm, cât mai clar posibil, bazele utilizării firelor de execuţie în Java.
11.2 Fire de execuţie (thread-uri) în Java Pentru ca programatorul Java să poată realiza aplicaţii multifir, Java oferă în pachetul java.lang, deci chiar în java core, două clase şi o interfaţă: clasa Thread clasa ThreadGroup interfaţa Runnable Clasa Thread şi interfaţa Runnable oferă suport pentru lucrul cu fire de execuţie, ca entităţi separate ale aplicaţiei iar clasa ThreadGroup permite crearea unor grupuri de fire de execuţie, în vederea tratării acestora într-un mod unitar. Notaţie UML pentru
interfaţă Runnable
Relaţie de realizare
Thread ThreadGroup
Figura 23. Resurse Java predefinite pentru programarea multifir. Relaţiile dintre ele.
După cum se poate observa, în Figura 23, clasa Thread implementează interfaţa Runnable iar clasa ThreadGroup se compune din mai multe obiecte Thread. Simbolurile folosite pentru a indica relaţia de compunere dintre ThreadGroup şi Thread, precum şi relaţia de realizare dintre Thread şi Runnable sunt de provenienţă UML. Deoarece discuţia referitoare la grupuri de fire se bazează pe înţelegerea lucrului cu fire independente, în continuare ne vom ocupa de problema utilizării firelor de execuţie independente. Pentru a crea un fir de execuţie în Java avem două posibilităţi: Definirea unei clase derivate din clasa Thread Definirea unei clase care implementează interfaţa Runnable
Crearea unui fir de execuţie derivând clasa Thread Alegând această variantă, avem de efectuat un număr redus de operaţii: Definirea unei clase derivate din clasa Thread. Derivarea se face, după cum se ştie, cu o sintaxă de tipul:
class FirulMeu extends Thread { //Date membre //Funcţii membre } Suprascrierea funcţiei public void run(), moştenită de la clasa Thread, în clasa derivată. Această metodă trebuie să implementeze comportamentul firului de execuţie. Aşa cum metoda main() este metoda apelată de Java Runtime System în momentul în care se execută o aplicaţie Java, metoda run() este metoda apelată când se execută un fir. De fapt, trebuie să subliniem că atunci când se porneşte maşina virtuală Java (JVM), odată cu ea se porneşte un fir de execuţie care apelează metoda main(). JVM îşi va înceta execuţia în momentul in care nu mai există fire în execuţie sau a fost apelată metoda exit() a clasei System. Instanţierea unui obiect fir, folosind operatorul new: FirulMeu firulmeu=new FirulMeu();
Pornirea firului instanţiat, prin apelul metodei start(), moştenită de la clasa Thread. firulmeu.start(); Acestea sunt operaţiile strict necesare pentru a începe lucrul cu fire de execuţie în Java, utilizând clasa Thread. Exemplul 11.1 //Clasa care modeleaza firul class TFirPers extends Thread { static int id=0; int[] vect=new int[10]; //Constructorul clasei public TFirPers() { }; //Metoda run(), care modeleaza comportamentul firului public void run() { id++; System.out.println("Lucreaza TFirPers.... "+id); for(int j=0;j<10;j++) vect[j]=j; }; }; //Clasa care modeleaza aplicatia public class Fire { public static void main(String sir[]) { int[] vecmain=new int[20]; //Declarare referinte TFirPers fir1,fir2; //Alocare referinte-fir de executie
fir1=new TFirPers(); fir2=new TFirPers(); //Lansarea in executie a firelor fir1.start(); fir2.start(); try { //Intarzierea firului principal pentru //a lasa ragaz firelor derivate din Thread //sa lucreze Thread.currentThread().sleep(2000); } catch(InterruptedException e) {} //Valorificarea rezultatelor furnizate de //cele doua fire de executie for(int k=0;k<10;k++) vecmain[k]=fir1.vect[k]; for(int l=0;l<10;l++) vecmain[l+10]=fir2.vect[l]+10; for(int i=0;i<20;i++) System.out.println(vecmain[i]); }; }; Codul Java, prezentat în Exemplul 11.1, face apel la un mic subterfugiu (adormirea firului principal de executie timp de 2 secunde pentru a lăsa timp firelor de executie, paralele firului principal, să-şi îndeplinească atribuţiile. Dacă nu se acordă acest răgaz, se va observa că firul principal va accesa datele corespunzătoare firelor secundare înainte ca acestea să fie conforme aşteptărilor noastre. Altfel spus, paralelismul specific lucrului cu mai multe fire de execuţie este efectiv şi, prin Exemplul 11.1, se atrage deja atenţia asupra modificării atitudinii programatorului faţă de problema organizării structurilor de prelucrare.
Crearea unui fir de execuţie utilizând interfaţa Runnable O altă modalitate de a crea fire de excuţie este utilizarea interfeţei Runnable. Această modalitate devine interesantă în momentul în care se
doreşte ca o clasă de tip Thread, pe care o implementăm, să moştenească capabilităţi disponibile în alte clase. Operaţiile specifice creării unui fir de execuţie utilizând interfaţa Runnable sunt următoarele: Definirea unei clase care implementează interfaţa Runnable. Aceasta se face utilizând sintaxa adecvată şi implementând cel puţin metodele interfeţei Runnable (de fapt, doar metoda public void run()). class FirRunnable extends Exemplu implementes Runnable { //Definitie } Clasa care implementează interfaţa Runnable trebuie să suprascrie funcţia public void run(). public void run() { //Cod aferent } Se instanţiază un obiect al clasei de mai sus, cu o sintaxă de tipul: FirRunnable obiectRunnable=new FirRunnable(); Se crează un obiect de tip Thread, utilizând un constructor care are ca şi parametru un obiect de tip Runnable. În acest mod se asociază un fir cu o metodă run(). Thread firulMeu=new Thread(obiectRunnable); În sfârşit, se porneşte firul, la fel ca în metoda derivării din Thread a firului. Paşii precizaţi mai sus se pot vedea şi în Exemplul 11.2 Exemplul 11.2 //Clasa care modeleaza aplicatia public class FirRunn {
public static void main(String s[]) { System.out.println("Creare obiect Runnable..."); classRunnable obiectRunn=new classRunnable(); System.out.println("Creare fir..."); Thread fir=new Thread(obiectRunn); System.out.println("Start fir..."); fir.start(); System.out.println("Din nou in main()..."); } } //Clasa auxiliara class Display { public void display(String mesaj) { System.out.println(mesaj); } } //Clasa care implementeaza interfata Runnable si mosteneste //clasa Display class classRunnable extends Display implements Runnable { public void run() { int nrpasi=3; display("Run are "+nrpasi+" pasi de facut..."); for(int i=0;i<3;i++) display("Pasul: "+i); display("Run si-a terminat munca..."); } }
Controlul unui fir de execuţie Problema controlului unui fir de execuţie este legată de cunoaşterea stărilor posibile ale firelor de execuţie. În speţă, pe timpul execuţiei unui program Java multifir, o instanţă Thread poate să se afle în una din următoarele patru stări: new, runnable, blocked şi dead. Atunci când creăm un fir de execuţie, acesta intră în starea new. În această stare firul de execuţie aşteaptă apelarea metodei start() a firului. Nici un cod nu rulează încă.
În starea runnable un fir execută codul prezent în metoda sa run(). Pentru ca firul să treacă din starea new în starea runnable trebuie executată metoda start(). Nu se recomandă apelarea directă a metodei run() deoarece face acest lucru, în locul dumneavostră, metoda start(). Când un fir de execuţie este inactiv despre el se spune că este în starea blocked sau not runnable. Un fir poate deveni inactiv dacă apelăm metode precum sleep(), suspend() sau wait(), ori dacă trebuie să aştepte după anumite metode I/O până la finalizarea execuţiei acestora. După cum se va vedea, fiecare dintre aceste metode are un mecanism propriu de refacere a stării runnable pentru fir. În Exemplul 11.1 am folosit deja metoda sleep() pentru a rezolva o problemă banală de sincronizare între firul principal de execuţie şi firele secundare.
Suspendarea şi reluarea execuţiei unui fir Am văzut deja cum putem suspenda execuţia unui fir pentru o perioadă de timp prin utilizarea metodei sleep() a clasei Thread. Putem suspenda execuţia unui fir şi până la apariţia unor condiţii obiective de reluare a execuţiei. În acest sens putem utiliza metoda suspend() a clasei Thread, care pune un fir în starea not runnable, până la apelarea metodei resume(). Ca un exemplu, utilizarea metodei suspend() permite oprirea unei secvenţe de animaţie la apăsarea butonului mouse-ului şi reluarea animaţiei la ridicarea degetului de pe butonul mouse-ului. De asemenea, un gen special de suspendare/reluare a execuţiei unui fir se realizează şi cu ajutorul perechii de metode wait()/notify() asupra căreia vom reveni mai jos.
11.3 Sincronizarea firelor Dacă sunt executate asincron, mai multe fire care partajează anumite date s-ar putea să fie obligate să-şi sincronizeze activităţile pentru a obţine rezultate corecte. Pe lângă posibilităţile pe care le oferă metode precum sleep() sau suspend()/resume(), în Java a fost introdus modificatorul synchronized, tocmai pentru a introduce un cadru adecvat atomizării activităţilor, în condiţii de concurenţă la resurse. Ideea de bază a modificatorului synchronized este cât se poate de simplă: primul fir care intră în posesia unui obiect marcat de modificatorul synchronized rămâne proprietar al obiectului până când îşi termină execuţia. În acest mod se crează un cadru simplu pentru evitarea coliziunilor în timpul accesului concurent la resurse. Ceea ce este simplu nu este întotdeauna şi eficient. Uneori, preţul sincronizării s-ar putea să fie mai mare decât poate suporta clientul aplicaţiei (timpii de execuţie pot fi diminuaţi drastic).
Sincronizare bazată pe modificatorul synchronized Modifcatorul synchronized poate fi utilizat pentru a realiza sincronizarea firelor. Orice fir are propia sa memorie de lucru, unde îşi ţine copii proprii ale variabilelor pe care le utilizează. Când este executat un fir, acesta operează numai asupra acestor copii. Memoria principală (main memory), asociată firului principal, conţine copia master a fiecărei variabile. Există reguli care condiţionează modul în care se poate efectua schimb de conţinut între cele două tipuri de copii ale variabilelor. Important pentru sincronizare este, însă, faptul că memoria main conţine şi zăvoare, care pot fi asociate obiectelor sau metodelor declarate synchronized. Firele pot intra în competiţie pentru achiziţionarea zăvoarelor. Acţiunile de zăvorâre şi dezăvorâre (dacă un astfel de cuvânt există!) sunt atomice, asemenea acţiunilor de citire sau scriere. Aceste zăvoare pot fi utilizate pentru sinconizarea activităţilor unui program multifir. Declararea ca synchronized a unui obiect sau a unei metode determină asocierea acestora cu un zăvor. Important este că un singur fir, la un moment dat, poate să închidă zăvorul, altfel spus, un singur fir poate deţine obiectul asociat cu zăvorul. Dacă un fir vrea să acceseze un obiect sau o metodă sincronizată, dar găseşte zăvorul închis, el trebuie să aştepte într-o coadă, până când zăvorul va fi deschis de către proprietarul lui circumstanţial. Astfel că, în aplicaţiile Java multifir, putem întâlni: sincronizare cu metode, sinconizare pe blocuri, sincronizare cu obiecte, pentru a introduce la anumite nivele, disciplina de utilizare mutual exclusivă a acestor trei categorii de concepte. Elemente de sintaxă şi aspecte referitoare la modul de utilizare a acestor tehnici se pot urmări în Exemplul 11.3, Exemplul 1.4 şi Exemplul 11.5. Modelele teoretice utilizate în limbajele care oferă suport pentru programarea multifir oferă şi alte soluţii la problema sincronizării. Java oferă suport pentr majoritatea acestor modele, remarcându-se, faţă de alte limbaje, prin aducerea problemei concurenţei în interiorul limbajului, spre deosebire de alte soluţii, care se bazează pe enunţuri sistem pentru implementarea prelucrărilor multifir. Pentru informarea cititorului, două mari direcţii de rezolvare a problemelor de sincronizare sunt: monitoarele (introduse de C.A.R. Hoare) şi semafoarele (introduse de Dijkstra). Fiecare dintre aceste soluţii pune în discuţie concepte precum secţiunea critică, prin care se înţelege o porţiune de cod la care accesul concurent trebuie monitorizat, pentru a evita disfuncţiile în utilizarea anumitor resurse. Atrag atenţia cititorului şi asupra ofertei limbajului Java în ceea ce priveşte posibilitatea de a defini grupuri de fire, a căror manevrare unitară poate constitui un avantaj, în anumite situaţii.
De asemenea, în Java există şi posibilitatea de a defini nişte fire speciale, numite daemon-i, fire a căror destinaţie este asigurarea de servicii pentru celelalte fire de execuţie. Exemplul clasic de daemon, în Java este firul care asigură funcţia de garbage collector. Exemplul 11.3 //Ilustreaza sincronizarea bazata pe obiecte //Obiectul monitor este balanta. class unFir extends Thread { //Obiectul monitor static Integer balanta = new Integer(1000); static int cheltuieli=0; public void run() { int vol; for(int i=0;i<10;i++) { try { sleep(100); } catch(InterruptedException e){} int bon=((int)(Math.random()*500)); //Accesul la blocul de cod de mai jos este //monitorizat cu ajutorul obiectului balanta synchronized(balanta) { if(bon<=balanta.intValue()) { System.out.println("Verif:"+bon); balanta=new Integer(balanta.intValue()-bon); cheltuieli+=bon; System.out.print("Balanta: "+balanta.intValue()); System.out.println("Cheltuieli: "+cheltuieli); } else { System.out.println("Respins: "+bon); }
} } } } public class Lacat { public static void main(String s[]) { new unFir().start(); new unFir().start(); } } Exemplul 11.4 //Ilustreaza sincronizarea cu obiecte sinchronized //apeland la wait() si notify() class Fir1 extends Thread { Object ob; Fir1(Object obi) { ob=obi; } public void run() { while(true) { System.out.println("Firul "+getName()); try { synchronized(ob) { ob.wait(); } } catch(InterruptedException e) {} } } }
class Fir2 extends Thread { Object ob; Object obman=new Object(); Fir2(Object obi) { ob=obi; } public void run() { while(true) { System.out.println("Firul "+getName()); try { synchronized(ob) { ob.notify(); } } catch(Exception e) { System.out.println("Exceptie: "+e); } try { synchronized(obman) { obman.wait(2000); } } catch(InterruptedException e) {} } } } public class WaitNoti { public static void main(String[]s)
{ //Obiect pretext pentru sincronizare Object obiect=new Object(); //Obiect pe care se face asteptarea Object obman=new Object(); Fir1 fir1=new Fir1(obiect); Fir2 fir2=new Fir2(obiect); fir1.start(); fir2.start(); try { synchronized(obman) { obman.wait(35000); } } catch(InterruptedException e) {} } } Exemplul 11.5 //Ilustreaza sincronizarea cu metode synchronized class Distribuitor { int marfa=0; //Metoda atomizata cu ajutorul modificatorului //synchronized public synchronized int consuma() { int temp; while(marfa==0) { try { wait(); } catch(InterruptedException e) {} }
temp=marfa; marfa=0; System.out.println("Consumat :"+temp); notify(); return temp; } //Metoda atomizata cu ajutorul modificatorului //synchronized public synchronized void produce(int vol) { while(marfa!=0) { try { wait(); } catch(InterruptedException e) {} } marfa=vol; notify(); System.out.println("Produs :"+marfa); } } class unFir extends Thread { boolean producator=false; Distribuitor distr; public unFir(Distribuitor d,String t) { distr=d; if(t.equals("Producator")) producator=true; } public void run() { for(int i=0;i<20;i++) { try {
sleep((int)(Math.random()*1000)); } catch(InterruptedException e) {} if(producator) distr.produce((int)(Math.random()*6)+1); else distr.consuma(); } } } public class ProdCons { public static void main(String s[]) { Distribuitor dis=new Distribuitor(); new unFir(dis,"Consumator").start(); new unFir(dis,"Producator").start(); } } Exemplul 11.5 ne arată cum putem combina sincronizarea bazată pe metode synchronized cu posibilităţile oferite de sincronizarea bazată pe aşteptare. Esenţiale, în sincronizarea bazată pe aşteptare, sunt metodele wait() şi notify().
Capitolul 12 Spiritul orientării pe obiecte în două aplicaţii C++
12.1 Consideraţii introductive
Oricât de mare ar fi emoţia pe care o provoacă o teorie printre curioşi, aceasta nu este suficientă pentru a o menţine în atenţia practicienilor, fie şi vremelnic. De aceea, în acest capitol voi încerca să arăt cum se pun în mişcare o parte din ideile orientării pe obiecte, în speranţa că, în acest fel, voi oferi motive în plus cititorilor de a încerca, “pe viu”, gustul orientării pe obiecte. Vom redescoperi, la sfârşitul acestui capitol, ceea ce am semnalat la începutul cărţii: programarea orientată pe obiecte, în adevăratul sens, este o problemă de atitudine faţă de întreg travaliul de realizare a unui sistem soft sau chiar program. Pentru a nu ne abate de la spiritul orientării pe obiecte, trebuie să utilizăm, cu răbdare, conceptele şi principiile pe care le-am prezentat deja, precum şi experienţa acumulată de alţi specialişti în realizarea de sisteme orientate pe obiecte. Prima problemă pe care o supun atenţiei cititorului este relativ simplă, dar foarte potrivită pentru a ilustra spiritul orientării pe obiecte în acţiune.
12.2 Aplicaţia 1 Enunţul Să se scrie codul C++ care simulează vizualizarera unui fişier text, în regim de scroll, pe ecranul unui calculator, utilizat în mod text. Efectul de scroll se va urmări numai pe verticală.
12.2.1 Observaţii introductive Enunţul pe care ni-l asumăm are, în mod intenţionat, o serie de elemente care ar putea fi taxate drept scăpări de către programatorii cu simţ critic dezvoltat. Astfel, aceştia se pot întreba: de ce în mod text, într-o lume în care modul grafic a inundat fiecare aplicaţie şi de ce efectul de scroll numai pe verticală? Pur şi simplu, din raţiuni didactice. Nimeni nu va putea opri elanul creator al cititorului să se manifeste cu puteri înzecite după ce va fi înţeles mesajul pe care vreau să-l transmit în această carte.
12.2.2 Soluţia problemei Câteva consideraţii relativ la cerinţele faţă de cod Codul va fi orientat pe obiecte şi va fi gândit în respect fată de principiul încapsulării. Doar faţă de principiul încapsulării, deoarece nu vom avea o ierarhie de clase şi prin urmare nici moştenire, nici polimorfism dinamic. Evident, că am fi putut gândi o soluţie ceva mai savantă, care să ia în considerare derivarea clasei care modelează vizualizarea unui fişier text
dintr-o clasă fereastră, capabilă să asigure un context vizual adecvat vizualizării şi o gestiune flexibilă a evenimentelor adresate acestei ferestre. Se poate reflecta la această posibilitate ca la un exerciţiu. Rămânând la exigenţele pe care ni le-am asumat iniţial, va trebui să specificăm o clasă C++ care să funcţioneze ca un “înveliş” comportamental pentru un fişier text. Acest înveliş va trebui să permită: asignarea la un fişier cu nume extern specificat, şi simularea efectului de scroll. Interfaţă acestei clase, din punct de vedere al utilizatorului, trebuie redusă la minimum. Totodată, pentru a da un exemplu de organizare a unui proiect în C++, vom păstra în fişiere diferite: interfaţa, implementarea ei şi programul care exemplifică modul de utilizare. Pentru a asigura condiţii optime de efectuare a scroll-ului (viteză şi evitarea uzurii capului de citire al unităţii de memorie externă), vom asocia fişierului text o imagine în RAM, ca listă dublu înlănţuită. Modelul de rezolvare a problemei este, practic, schematizat în Figura 24, unde se poate vedea că, din punct de vedere algoritmic, trebuie doar să găsim o rezolvare elegantă la problema mapării conţinutului fişierului text pe ecranul monitorului, conform dorinţelor utilizatorului. Să mai menţionăm că problema poate fi abordată şi altfel: dimensiunea ferestrei în care se face scroll să fie configurabilă. În acest caz, datele problemei se modifică semnificativ, dar soluţia este evident superioară calitativ alegerii noastre. Se poate privi ca un exerciţiu posibil şi această abordare. Ecranul monitorului
curent
aple
aule
aple - adresa primei linii care se vede pe ecran aule - adresa ultimei linii care se vede pe ecran curent - adresa elementului care se află în rezonanţă cu cursorul. Imagine memorie fişier (IMF)
Figura 24. Relaţia ecran-Imagine Memorie Fişier
Specificarea UML a clasei care modelează comportamentul obiectului “scroll” Pornind de la contextul problematic schiţat în Figura 24 şi ţinând cont de cerinţele asumate mai sus, putem propune definiţia UML a clasei, să-i spunem, scroll. Această definiţie va urmări: Furnizarea unei interfeţe simple şi stabile, în acord cu cerinţele formulate mai sus. Separarea interfeţei de implementare, asigurând protecţia necesară operaţiilor interne ale clasei, prin declararea lor ca private. Securizarea accesului la datele membre ale clasei, prin declararea lor ca private. Să mai menţionăm că rămâne deschisă problema fiabilizării codului pe care îl propunem, ceea ce ar însemna furnizarea de suoort structurat pentru tratarea excepţiilor. Înainte de a prezenta specificarea şi implementarea C++ a clasei scroll, prezentată în notaţie UML în Figura 25, să mai atragem atenţia cititorului asupra unui “amănunt” important: metoda insert(), gândită ca făcând parte din definiţia clasei scroll, putea foarte bine să fie membru într-o clasă specializată în lucrul cu liste dublu înlănţuite. De exemplu, o clasă de genul celor pe care le-am pus în discuţie în Capitolele 3, 4 şi 5 sau orice instrument orientat pe obiecte echivalent. scroll -char *numef; -struct Nod *start; -struct Nod *prec; -struct Nod *aple, -struct Nod *aule, -struct Nod *curent; -ifstream fin; -void -void -void -void -void -void
insert(char *el); loadfis(); initscreen(); scrollup(); scrolldown(); runscroll();
+scroll(char *nf); +Nod * getstart();
Figura 25. Definiţia UML a clasei scroll
Specificarea clasei “scroll” în C++ Potrivit practicilor legate de gestiunea proiectelor C++ de complexitate mai mare, definiţiile claselor se păstrează în fişiere antet cu extensia “.h”. Ilustrăm aceasta practică prin fişierul scroll.h. de mai jos. //”scroll.h” //Fisierul antet care contine definitia clasei scroll //si alte definitii ajutatoare //Structura nodului listei dublu inlantuite //in care se pastreaza imaginea memorie a //fisierului(IMF) struct Nod { char *linie; struct Nod* legs,*legd; }; //Clasa care modeleaza un fisier scroll-abil class scroll { //Sectiunea membrilor privati char *numef; struct Nod *start,*prec; struct Nod *aple,*aule,*curent; ifstream fin; //Inserare element in lista dublu inlantuita void insert(char *el); //Creare imagine memorie fisier void loadfis(); //Initializare ecran pentru scroll void initscreen(); //Derulare scroll void runscroll();
//Efectuare scroll-up void scrollup(); //Efectuare scroll-down void scrolldown(); //Sectiunea resurse publice public: //Constructor scroll(char *nf); //Selector adresa de start IMF Nod * getstart(); };
Implementarea clasei “scroll” în C++ Din nou, potrivit practicilor legate de gestiunea proiectelor C++ de complexitate mai mare, implementarea claselor se păstrează în fişiere antet cu extensia “.cpp”. Ilustrăm aceasta practică prin fişierul scroll.cpp. de mai jos. //”scroll.cpp” //Fisier antet care contine implementarea clasei //din scroll.h #include <iostream.h> #include <conio.h> #include <fstream.h> #include <string.h> #include <stdlib.h> #include "scroll.h" scroll::scroll(char *nf) { numef=new char[strlen(nf)]; strcpy(numef,nf); fin.open(numef); start=NULL; loadfis(); initscreen(); gotoxy(1,1); curent=start; runscroll();
}; //Selector adresa de start IMF Nod * scroll::getstart() { return start; }; //Implementare scrollup() void scroll::scrollup() { if (wherey()>1) { gotoxy(1,wherey()-1); curent=curent->legs; } else { if (aple->legs!=NULL) { gotoxy(1,1); insline(); gotoxy(1,1); aple=aple->legs; aule=aule->legs; cout<<aple->linie<<endl; gotoxy(1,1); } }; }; //Implementare scrolldown() void scroll::scrolldown() { if ((wherey()<21)&(curent!=aule)) { gotoxy(1,wherey()+1); curent=curent->legd; } else { if (aule->legd!=NULL)
{ gotoxy(1,1); delline(); gotoxy(1,21); aule=aule->legd; aple=aple->legd; cout<<aule->linie<<endl; } } }; //Implementare element in lista void scroll::insert(char *el) { Nod *w; if (start==NULL) { start=new Nod; start->linie=new char[strlen(el)+1]; strcpy(start->linie,el); start->legs=NULL; start->legd=NULL; prec=start; } else { w=new Nod; w->linie=new char[strlen(el)+1]; strcpy(w->linie,el); prec->legd=w; w->legs=prec; w->legd=NULL; prec=w; }; }; //Implementare loadfis() void scroll::loadfis() { char lin[80]; clrscr(); while(!fin.eof())
{ fin.getline(lin,80); insert(lin); } }; //Implementare initScreen() void scroll::initscreen() { Nod *aloc; aple=aloc=aule=start; clrscr(); while((aloc!=NULL)&(wherey()<22)) { cout<<aloc->linie<<endl; aule=aloc; aloc=aloc->legd; }; }; //Implementare runscroll() //Se raspunde la: // sageata sus // sageata jos // ESCAPE void scroll::runscroll() { int tasta,tasta1; do { do { tasta=getch(); } while((tasta!=27)&(tasta!=0)); switch(tasta) { case 0: { tasta1=getch(); switch(tasta1)
{ case 72: scrollup();break; case 80: scrolldown();break; };break; } case 27: exit(1); }; } while(1); }; În sfârşit, prezentăm, mai jos, un exemplu de context C++ în care se poate vedea modul de utilizare a unui obiect de tip scroll. Se poate vedea simplitatea modului de utilizare, datorată simplităţii interfeţei. Se pot, de asemenea, anticipa, în urma anlizei implementării pe care am furnizat-o mai sus, evenimentele neplăcute care pot apare la o utilizare greşită a interfeţei clasei scroll. De exemplu, intenţia de scroll asupra unui fişier inexistent sau inabordabil din variate motive. Rămâne ca exerciţiu, pentru cititor, fiabilizarea codului sau îmbunătăţirea modului de comunicare cu clienţii clasei scroll, în caz de apariţie a unor excepţii. #include "scroll.cpp" void main() { scroll sco("SCroll.cpp"); };
12.3 Aplicaţia 2 Enunţul Să se scrie codul C++ care simplifică operaţiile de intrare/ieşire, relativ la fişiere, care au în vedere tipurile de date fundamentale.
12.3.1 Observaţii introductive De data aceasta se doreşte rezolvarea unei probleme, de real interes pentru un număr mare de utilizatori: programatori care consideră că flexibilitatea pe care le-o oferă C++ în gestiunea colecţiilor de date păstrate pe suporturi de memorie externă este o provocare care nu poate fi ocolită. Nu toţi programatorii C++ tânjesc după un instrument care simplifică, şi prin aceasta îngrădeşte, operaţiile I/O. Există, însă suficiente argumente pentru a încerca să
furnizăm un înveliş convenabil soluţiei C++ la problema operaţiilor cu datele păstrate în fişiere. Acest înveliş va permite, în final, un protocol de lucru cu tipurile de date fundamentale, asemănător celui întâlnit în Pascal, Object Pascal, Basic, Visual Basic, etc.
12.3.2 Soluţia problemei Câteva consideraţii relativ la cerinţele faţă de cod Codul va fi orientat pe obiecte şi va fi gândit în respect fată de principiul încapsulării. Doar faţă de principiul încapsulării, deoarece nici în acest caz nu vom avea o ierarhie de clase şi prin urmare nici moştenire, nici polimorfism dinamic. Din experienţa lucrului cu fluxuri în C++ se ştie că acestea pot fi utilizate în citire sau în scriere. Atenţie la interfaţă şi la funcţionalitatea expusă de aceasta. Utilizatorii interfeţei sunt programatori. Soluţia pe care o propunem trebuie să simplifice operaţiile I/O cu tipuri fundamentale de date şi, totodată, trebuie să conserve spiritul liberal în care se programează în C++.
Specificarea UML a învelişului pentru operaţiile I/O Decizia pe care am luat-o este, probabil, una din o mie posibile. Serviciile pe care le aşteptăm de la framework vor fi expuse prin intermediul a două clase: clasa FileIn, specializată în simularea operaţiilor de citire a tipurilor fundamentale de date din fişiere şi clasa FileOut, specializată în simularea operaţiilor de scriere a tipurilor fundamentale de date în fişiere. Impreună, aceste clase rezolvă problema accesului la tipurile fundamentale de date dintrun fişier, cu menţiunea că nu s-a luat în calcul posibilitatea de a avea, simultan, acces şi în citire şi în scriere. Cititorul poate studia problema regândirii soluţiei în ideea că se doreşte suport şi pentru situaţiile în care dorim, simultan, acces I/O. Oricare ar fi situaţia, între fişierul care conţine datele şi programator se află, în imediată apropiere, clasele framework-ului şi, disimulate de aceste clase, fluxurile C++. Figura 26 arată, în notaţie UML, cele două clase discutate mai sus.
Specificarea claselor înveliş în C++ În cele ce urmează, voi prezenta codul C++ care realizează definiţia claselor FileIn şi FileOut, menţionate mai sus. Atrag atenţia cititorului asupra faptului că extensia dată noţiunii de tip fundamental de date este discutabilă. Discutabilă este şi lipsa unei strategii specifice de tratare a excepţiilor care pot apare în timpul lucrului cu obiectele, având aceste clase drept clase definitoare. Aşadar, subiect de reflexie pentru cititor, care ilustrează o idee deja menţionată în această carte: în industria de soft, orice problemă, înainte de a fi o
problemă de programare este o problemă de modelare în spiritul unei anumite paradigme. Deciziile care se iau în faza de specificare a cerinţelor faţă de un sistem soft, de exemplu, influenţează în mod hotărâtor, arhitectura soluţiei sistemului soft în cauză.
FileIn -ifstream iflux; -char * numef; +FileIn(); +FileIn(char *numef); +void setNumeF(char *numef); +char *getNumeF(); +void fileOpen(); +void fileClose(); + int readInt(); +float readFloat(); +double readDouble(); +long int readLInt();
FileOut -ofstream oflux; -char * numef; +FileOut(); +FileOut(char *numef); +void setNumeF(char *numef); +char *getNumeF(); +void fileOpen(); +void fileClose(); +void writeInt(int nr); +void writeFloat(float nr); +void writeDouble(double nr); +void writeLInt(long int nr); Figura 26. Specificarea UML a claselor FileIn şi FileOut
//FiĹ&#x;ierul file.h #include <fstream.h> //Fisierul contine definiĹŁia claselor wrapper pentru operatiile I/O //cu tipuri predefinite, relativ la fisiere //****************************************************** //Simulare operatii INPUT class FileIn { //Atribute informationale private //Flux input ifstream iflux; //Nume fisier extern asociat fluxului char * numef; //Interfata clasei public: //Constructori FileIn(); FileIn(char *numef); //Setare nume fisier extern void setNumeF(char *numef); //Consultare nume fisier extern char *getNumeF(); //Deschidere flux in cazul utilizarii constructorului File() void fileOpen(); //Inchidere flux void fileClose(); //Citire intreg din fisier int readInt(); //Citire real simpla precizie din fisier float readFloat();
//Citire real dubla precizie din fisier double readDouble(); //Citire long int din fisier long int readLInt(); }; //Simulare operatii OUTPUT class FileOut { //Atribute informationale private //Flux output ofstream oflux; //Nume fisier extern asociat fluxului char * numef; //Interfata clasei public: //Constructori FileOut(); FileOut(char *numef); //Setare nume fisier extern void setNumeF(char *numef); //Consultare nume fisier extern char *getNumeF(); //Deschidere flux in cazul utilizarii constructorului File() void fileOpen(); //Inchidere flux void fileClose(); //Scriere intreg in fisier void writeInt(int nr);
//Scriere real simpla precizie in fisier void writeFloat(float nr); //Scriere real dubla precizie in fisier void writeDouble(double nr); //Scriere long int in fisier void writeLInt(long int nr); };
Implementarea claselor înveliş în C++ //Fişierul iofile.cpp //Fisierul contine implementarea metodelor claselor FileIn si FileOut //****************************************************** #include "file.h" #include <string.h> #include <alloc.h> //Implementarea metodelor clasei FileIn FileIn::FileIn(char *nf) { numef=(char*)malloc(strlen(nf)+1); strcpy(numef,nf); iflux.open(numef,ios::in|ios::binary); }; FileIn::FileIn() { }; int FileIn::readInt() { int n; iflux.read((signed char*)&n,sizeof(int)); return n; };
float FileIn::readFloat() { float n; iflux.read((signed char*)&n,sizeof(float)); return n; }; double FileIn::readDouble() { double n; iflux.read((signed char*)&n,sizeof(double)); return n; }; long int FileIn::readLInt() { long int n; iflux.read((signed char*)&n,sizeof(long int)); return n; };
void FileIn::setNumeF(char *nf) { numef=(char*)malloc(strlen(nf)+1); strcpy(numef,nf); }; char *FileIn::getNumeF() { return numef; }; void FileIn::fileOpen() { iflux.open(numef,ios::in|ios::binary); }; void FileIn::fileClose() { iflux.close(); };
//Implementarea metodelor clasei FileOut FileOut::FileOut(char *nf) { numef=(char*)malloc(strlen(nf)+1); strcpy(numef,nf); oflux.open(numef,ios::out|ios::binary); }; FileOut::FileOut() { }; void FileOut::writeInt(int nr) { oflux.write((signed char*)&nr,sizeof(int)); }; void FileOut::writeFloat(float nr) { oflux.write((signed char*)&nr,sizeof(float)); }; void FileOut::writeDouble(double nr) { oflux.write((signed char*)&nr,sizeof(double)); }; void FileOut::writeLInt(long int nr) { oflux.write((signed char*)&nr,sizeof(long int)); }; void FileOut::setNumeF(char *nf) { numef=(char*)malloc(strlen(nf)+1); strcpy(numef,nf); }; char *FileOut::getNumeF() { return numef; };
void FileOut::fileOpen() { oflux.open(numef,ios::out|ios::binary); }; void FileOut::fileClose() { oflux.close(); };
Un exemplu de utilizarea a claselor FileIn Ĺ&#x;i File Out //Fisierul contine un program de test pentru clasele FileIn si FileOut //****************************************************** #include <fstream.h> #include <conio.h> #include "filein.cpp" #include <iostream.h> void main() { int nri=1024; double nrrd=12.75; long int nrli=10000000; clrscr(); //Creare fisier de test FileOut of("fis.dat"); cout<<"Obiect FileOut creat in urma apelului vers. 1 a constructorului"<<endl; of.writeInt(nri); of.writeDouble(nrrd); of.writeLInt(nrli); of.fileClose(); //Prima utilizare statica a clasei FileIn FileIn fis("fis.dat"); cout<<"Obiect FileIn creat in urma apelului vers. 1 a constructorului"<<endl; nri=fis.readInt(); nrrd=fis.readDouble(); nrli=fis.readLInt();
cout<<nri<<" "<<nrrd<<" "<<nrli<<endl; fis.fileClose(); //A doua utilizare statica a clasei FileIn //Deschiderea fluxului este realizata in mai multi pasi FileIn fis1; fis1.setNumeF("fis.dat"); fis1.fileOpen(); cout<<endl; cout<<"Obiect FileIn creat in urma apelului vers. 2 a constructorului"<<endl; nri=fis1.readInt(); nrrd=fis1.readDouble(); nrli=fis1.readLInt(); cout<<nri<<" "<<nrrd<<" "<<nrli<<endl; getch(); }; Expediţia OO se opreşte aici, în această carte. Ea trebuie să continue, pentru fiecare cititor în parte, în toate direcţiile în care aceasta se manifestă din ce în ce mai insistent: mediile vizuale de programare, SGBD-urile orientate pe obiecte, dezvoltarea orientată pe componente, etc. În orice direcţie ar porni cititorul, în esenţă se va întâlni cu aceleaşi probleme dificile: Modelarea respectând spiritul orientării pe obiecte, care are numeroase avantaje dar se învaţă mai greu decât orice manieră de programare “artizanală”. Rezolvarea problemelor de “supravieţuire” a obiectelor, între două execuţii ale aplicaţiilor orientate pe obiecte. Rezolvarea problemelor de transmitere şi partajare a obiectelor între aplicaţii aflate pe acelaşi calculator sau pe calculatoare diferite, într-o reţea. Soluţionarea elegantă a problemei tratării excepţiilor care pot apare într-o aplicaţie orientată pe obiecte. Armonizarea cerinţelor orientării pe obiecte cu exigenţele realizării aplicaţiilor multifir, etc. Fără să fie un secret prea mare, deprinderea de a programa orientat pe obiecte vine după mult exerciţiu şi efort constant de informare cu privire la şabloanele aplicate de comunitatea programatorilor OO pentru rezolvarea unor probleme tip.
Bibliografie esenţială [1] Eckel, B., Thinking in Java, 2nd edition, Revision 12, format electronic. [2] Jamsa & Klander, C şi C++ (Manualul fundamental de programare în C şi C++), Editura Teora. [3] Joshua Bloch, Java. Ghid practic pentru programatori avansaţi, Editura Teora, 2002. [4] Lemay, L., Cadenhead, R., Java 2 fără profesor în 21 de zile, Editura Teora, 2000. [5] Mark C. Chan, ş.a., Java. 1001 secrete pentru programatori, Editura Teora [6] Negrescu,L., Limbajele C şi C++ pentru începători, Limbajul C++ (volumul II), Editura Albastră, Cluj-Napoca [7] Teixeira, S., Pacheco, X., Delphi 5 Developer’s Guide, SAMS Publishing, 2000