Upload
teodor-olteanu
View
341
Download
5
Embed Size (px)
Citation preview
Syllabus
I. Informatii generale
II.1 Datele de identificare a cursului
Datele de contact ale titularului de curs:
Nume: Gheorghe Cosmin Silaghi
Birou: Campus, etaj 4, sala 431
Telefon: 0722-817582
Fax: 0264-412570
Email: [email protected]
Consultatii: saptamanal, joia de la 12-14
Datele de identificare curs si contact tutori:
Numele cursului: Limbaje de programare
Codul cursului: EBI0085
Anul, semestrul: anul III, semestrul 1
Tipul cursului: obligatoriu
Pagina web a cursului: www.econ.ubbcluj.ro/~gsilaghi/limbaje2008
Tutori: Gheorghe Cosmin Silaghi, Mircea Moca
Adrese de email: [email protected], [email protected]
Conditionari si cunostiinte prerechizite:
Se recomanda cunostiinte de programare structurata in C. Aceste cunostinte sunt obtinute la
disciplina “Algoritmi si Structuri de date”, anul II, semestrul 2. Aditional, studentii pot opta
pentru cursul facultative de Introducere in Programare, anul II semestru 1.
Descrierea cursului
Cursul de Limbaje şi medii de programare îşi propune să familiarizeze studenţii cu noţiunile
principale legate de limbaje de programare. Studentii vor deprinde principalele noţiuni necesare
pentru învăţarea unui limbaj de programare. Aici includem considerente legate de structura şi
componentele unui limbaj de programare, modul de obţinere a unui program executabil dintr-un
program sursă, tehnici de programare utilizate în conceperea eficientă a programelor.
Pentru exemplificarea acestor notiuni, cursul va parcurge paradigma obiectuală şi va concretiza
notiunile teoretice ale paradigmei prin utilizarea limbajului C++. Alegerea paradigmei obiectuale
ca şi ţinta de exemplificare se datorează importanţei acestei paradigme în industria software,
tehnologia obiectuală reprezentând una din cerintele fundamentale din industrie.
Mediul de programare utilizat va fi Visual C 6.0.
Organizarea temelor in cadrul cursului
Temele cursului sunt organizare conform logicii de invatare a conceptelor de programare
obiectuala. Cursul de bazeaza pe logica bulgarelui de zapada. Astfel, la inceput se introduce
notiuni facile legate de aceasta disciplina. Pe parcurs, aceste notiuni se folosesc la descrierea si
invatarea altor notiuni mai complicate. Studentii sunt rugati sa consulte bibliografia aferenta
fiecarei teme, atat din suportul de curs obligatoriu (manualul) cat si din celelalte carti indicate.
Pentru fiecare tema, suportul de curs dezvolta conceptele teoretice, prezinta exemple si propune
probleme de rezolvat. Pentru o buna aprofundare, studentii trebuie sa parcurga la calculator
aceste exemple si sa realizeze problemele date ca si tema. Site-ul disciplinei contine aditional
slide-urile pentru fiecare tema. Slide-urile contin informatii sumare, fiind un bun ghid de
reamintire a conceptelor dezvoltate la fiecare capitol. Slide-urile nu sunt suficiente ca si mijloc de
invatare a acestei discipline.
Formatul si tipul activitatilor de curs
La intalnirile cu studentii, profesorul va prezenta continutul teoretic al disciplinei. Studentii sunt
rugati sa participle la aceste intalniri, pentru ca au posibilitatea sa inteleaga mai bine conceptele
disciplinei. Continutul este disponibil si in manual, studentii avnad posibilitatea sa-l insuseasca
individual. Studiul individual presupune exercitiu la calculator in limbajul de programare C++, cu
exemplele din manual sau cu alte exemple. Pentru o mai buna intelegere, studentii pot sa resolve
problemele propuse sau sa realizeze alte programe software, la liberal or alegere.
Pentru fiecare tema, studentii trebuie sa trimita problemele propuse rezolvate pentru a fi
punctuate de catre tutori.
Materiale bibliografice obligatorii
1. G.C. Silaghi, Mircea Moca „Limbaje de Programare. Metode Obiectuale. Ghid teoretic si
practic”. Editia 2-a, Ed. Risoprint, 2008
2. Bruce Eckel, „Thinking in C++”, ed. Prentice Hall, 2000, vol 1.
3. Bjarne Stroustrup, „The C++ Programming Language”, Addison-Wesley,
4. Bazil Pârv, Al. Vancea, „Fundamentele limbajelor de programare”, 1996
5. D.L. Şerbănaţi, „Limbaje de programare şi compilatoare”, Ed. Academiei, 1987
6. Malcom Bull, „Student’s guide to programming languages”, HB Newnes, 1992
Materiale si instrumente necesare pentru curs
- Laborator echipat cu MS Visual C 6.0
- videoproiector
Calendarul cursului
Cursul este impartit in capitole, aferente celor 14 saptamani ale unui semestru:
1. Introducere în limbaje de programare
1.1 Definirea limbajelor de programare
1.2 Clasificarea limbajelor
1.3 Istoricul şi evoluţia limbajelor
2. Fundamentele limbajelor de programare
2.1 Limbaje abstracte
2.2 Compilatoare
3. Introducere în obiectualitate
3.1 Principii de bază
3.2 Analiza şio designul programelor orientate obiect
3.3 Noţiuni de bază în C++
4. Abstractizare şi încapsulare
4.1 Abstractizarea
4.2 Ascunderea implementării
5. Construirea obiectelor
5.1 Constructori şi destructori
5.2 Supraîncărcarea funcţiilor
6. Tipuri de variabile
6.1 Constante
6.2 Funcţii inline
6.3 Elemente statice
6.4 Spaţii de nume
7. Transmiterea obiectelor între funcţii
7.1 Referinţe
7.2 Constructorul de copiere
8. Supraîncărcarea operatorilor
8.1 Realizarea supraîncărcării operatorilor
8.2 Excepţii de la regula de supraîncărcare
8.3 Considerente de design
8.4 Conversie automată de tip
9. Gestiunea dinamică a memoriei
9.1 Crearea dinamică a obiectelor
9.2 Mecanismul de gestiune dinamică a memoriei
10. Compoziţie şi moştenire
10.1 Reutilizarea codului
10.2 Definirea moştenirii
10.3 Introducerea diagramelor de clase
10.4 Considerente de design
11. Polimorfism
11.1 Definirea conceptului de polimorfism
11.2 Realizarea polimorfismului de tip în C++
11.3 Clase abstracte
11.4 Aspecte colaterale ale moştenirii
12. Colecţii
12.1 Genericitate. Realizarea genericităţii
12.2 Colecţii heterogene
12.3 Colecţii parametrizate
13. Tipuri abstracte de date
13.1 Definirea ADT-urilor
13.2 Specificarea ADT-urilor
14. Paradigme de programare colaterale
14.1 Programarea bazată pe evenimente
14.2 Programarea bazată pe componente
14.3 Programarea logică şi funcţională
Fiecare tema presupune parcurgerea unui material intre 15 si 30 de pagini si rezolvarea a 3
probleme practice. In saptamanile 5, 9 si 14 studentii trebuie sa trimita spre evaluare tutorilor
rezolvarile la problemele propuse din capitolele anterioare saptamanii respective.
Politica de evaluare si notare
Nota la aceasta disciplina este compusa din:
- 70% examen,
- 30% evaluarea temelor din timpul semestrului
Termenele pentru teme sunt saptamanile 5, 9, si 14. Pe fiecare tema se acorda o nota de la 1 la 10
si se face media acestor note. Aceasta medie conteaza 30% din nota finala.
Examenul din sesiune are o pondere de 70% din nota finala si este compus din examen scris si
practic. Examenul scris se compune dintr-un test grila cu 30 de intrebari, 5 intrebari deschise si o
problema. Examenul practice presupune rezolvarea unei probleme la calculator. Problema care se
rezolva la calculator are mai multe subpuncte, in ordine crescanda de dificultate. Nota
examenului este medie aritmetica a notelor de la proba scrisa si proba practica.
Elemente de deontologie academica
Stratregii de studiu recomandate
Se recomanda studentilor să parcurgă cursul gradual pe parcursul semestrului. Astfel, in timpul
parcurgerii fiecărui capitol, studentii sunt rugaţi să ruleze exemplele practice din material şi să
pună accept pe intelegerea conceptelor şi a modului in care programele sunt realizate si executate.
Studentii sunt incurajaţi să comunice cu tutorii in cazul in care există nelămuriri legate de
materialul cursului. Temele şi intrebările recapitulative indicate la fiecare capitol sunt minimale
pentru intelegerea si aprofundarea materialului. Recomandăm studentilor rezolvarea tuturor
problemelelor indicate in curs.
II. Suportul de curs propriu-zis
MODULUL 1. FUNDAMENTELE LIMBAJELOR DE PROGRAMARE
Scopul si obiectivele mobulului:
În acest capitol vom prezenta succint formalizarea exhaustivă a teoriei limbajelor de programare.
Materiale precum [Aho 1977], [Şerbănaţi 1987] conţin o tratare detaliată a acestui subiect. Teoria
limbajelor de programare este necesară pentru specificarea formală corectă a limbajelor şi
reprezintă un fundament pentru teoria compilării.
1.1. Limbaje abstracte
1.1.1. Gramatici
Fie o mulţime A nevidă, finită, numită alfabet. Elementele acestei mulţimi se numesc simboluri.
Un simbol din A este reprezentat într-un limbaj printr-o literă, cifră sau semn, uneori printr-un
şir finit de litere, cifre sau semne.
Notăm prin *A mulţimea aranjamentelor cu repetiţie ale simbolurilor din A . Astfel, un element
din *A este un şir finit de simboluri din A . Simbolurile se pot repeta în şir. Mulţimea *A
conţine şi şirul vid; vom nota acest şir cu λ . Numărul de simboluri dintr-un şir din *A se
numeşte lungimea şirului. Se observă că dacă avem două şiruri x şi y din *A atunci şirul
xyz = , obţinut prin alăturarea simbolurilor din x cu simbolurile din y , va fi de asemenea,
element al mulţimii *A . Operaţia prin care se obţine şirul z din şirurile x şi y se numeşte
concatenare. Mulţimea *A înzestrată cu operaţia de concatenare are o structură de monoid.
Prin definiţie, un limbaj formal peste alfabetul A este o submulţime L a lui *A .
Fiind dat un alfabet A şi mulţimea *)(AP a părţilor mulţimii *A , pe această din urmă mulţime
putem defini următoarele operaţii de bază:
a. intersecţie: 21 LL ∩
b. reuniune: 21 LL ∪
c. complementare: }|*{ LxAxL ∉∈=
d. produs: },|{ 22112121 LxLxxxLL ∈∈= . Notăm cu ,...2 LLL =
e. stea: ......}{* 2 ∪∪∪∪∪= nLLLL λ
f. reflectare: }|~{~
LxxL ∈= unde x~ este imaginea reflectată a lui x
În legătură cu un limbaj formal, se pune problema apartenenţei unei construcţii la limbaj, adică,
în condiţiile furnizării unui cuvânt *Ax∈ , dacă se poate decide (demonstra) una din următoarele
2 concluzii: Lx∈ sau Lx∉ .
Se spune că un limbaj L este decidabil, dacă răspunsul la întrebarea de mai sus este pozitiv.
Conceptul de algoritm stă la baza rezolvării acestei probleme. Adică, trebuie să decidem
răspunsul la întrebarea de apartenenţă într-un timp finit folosind operaţii precis definite. Problema
apartenenţei unui cuvânt la limbaj, în cazul limbajelor de programare trebuie să fie rezolvată de
compilator, adică acesta trebuie să decidă dacă codul sursă furnizat de programator satisface sau
nu regulile limbajului, adică poate fi compilat sau nu.
În cazul în care limbajul L este finit, atunci limbajul este decidabil. Dacă L este infinit, trebuie
să folosim alte metode pentru a răspunde la întrebarea de decidabilitate.
Specificarea limbajului înseamnă fie enumerarea tuturor elementelor acestuia, fie enunţarea unor
reguli de construcţie a elementelor limbajului. Noţiunea de gramatică stă la baza specificării unui
limbaj prin generarea tuturor cuvintelor sale.
Prin definiţie, un sistem formal este un cvadruplu ordonat >ℵ=< RFAS ,,, alcătuit din:
- alfabetul sistemului A
- mulţimea decidabilă a formulelor corecte, *AF ⊆
- mulţimea decidabilă a axiomelor, F⊆ℵ
- mulţimea finită a regulilor de deducţie (inferenţă) R . O regulă de deducţie de aritate 1+n
este o relaţie din mulţimea FF n × , care asociază o formulă unică x cu un n-tuplu
>=< nyyyy ,...,, 21 . Spunem că x se deduce din nyyy ,...,, 21 şi scriem Rxy .
Fie formulele corecte nyyy ,...,, 21 , numite premise. Fie ℵ∪= },...,,{ 210 nyyyE . Atunci, nE0
reprezintă mulţimea tuturor n-tuplelor cu formule din 0E . Atunci, aplicând succesiv regulile de
deducţie din R putem obţine mulţimile U1
11 },|{ incatastfel
≥−− ∈∃∪=
n
niii RxyEyxEE .
Dacă mulţimea premiselor e vidă, adică ℵ=0E atunci elementele lui iE se numesc teoreme.
Dacă x este o teoremă, atunci ea s-a obţinut prin aplicarea succesivă a unor reguli de deducţie
asupra unor formule din mulţimile iE . Secvenţa acestor reguli de deducţie alcătuieşte
demonstraţia teoremei x .
Prin definiţie, următorul caz particular de sistem formal:
a. alfabetul A al sistemului formal este finit şi este alcătuit din două mulţimi disjuncte N şi ∑ ,
alfabetul de simboluri neterminale respective alfabetul de simboluri terminale.
b. *AF = : toate şirurile finite pe alfabet sunt formule corecte
c. ℵ conţine un singur element, şi anume un simbol neterminal S . Acest simbol se numeşte
simbol de început.
d. Regulile de deducţii au la bază producţii: O producţie este o pereche ),( βα de formule,
notată cu βα → . Regula de producţie asociată unei producţii este o regulă de rescriere, adică
QPQP βα → unde P şi Q sunt 2 şiruri din F . Aceasta înseamnă că în orice formulă
corectă care conţine subşirul α se poate înlocui α cu β şi se obţine tot o formulă corectă.
Notăm cu P mulţimea finită de producţii.
În aceste condiţii, sistemul formal >∑=< SPNG ,,, se numeşte gramatică.
Procesul de inferenţă în cazul gramaticilor se numeşte derivare.
Spunem despre două şiruri δ şi ε din F că δ derivează imediat în ε în cadrul unei gramatici
date, dacă există două şiruri 1γ şi 2γ din F şi o producţie βα → astfel încât 21αγγδ = şi
21βγγε = . O asemenea relaţie de derivare reprezintă o derivare într-un singur pas. Derivarea se
poate defini pe k paşi, în cazul ε este obţinut din δ prin k derivări.
O relaţie de derivare a lui ε din δ este nebanală, dacă obţinerea lui ε se realizează într-un număr
nenul de paşi. Relaţia de derivare se numeşte generală dacă ε se obţine din δ într-un număr
nenul de paşi sau dacă cele 2 formule coincid.
Prin definiţie, un limbaj generat de o gramatică G este mulţimea tuturor propoziţiilor }{)( xGL =
cu proprietatea că S derivă general pe x .
Dacă limbajele generate de două gramatici coincid, se spune că gramaticile sunt echivalente.
Conform definiţiei gramaticilor enunţată de mai sus, Naom Chomsky le-a clasificat după forma
producţiilor. Astfel, avem următoarele tipuri de gramatici:
1. Clasa gramaticilor de tip 0: reprezintă cea mai generală clasă de gramatici, sunt cele care
respectă definiţia generală furnizată mai sus
2. Clasa gramaticilor de tip 1: producţiile sunt de forma: αγββα →A . A este un simbol
neterminal, γ este şir oarecare, diferit de simbolul vid. Aceste gramatici se mai numesc şi
dependente de context.
3. Clasa gramaticilor de tip 2: producţiile sunt de forma: α→A , unde A este un simbol
neterminal, α este şir oarecare. Aceste gramatici se mai numesc şi independente de context.
4. Clasa gramaticilor de tip 3 sau regulate: producţiile sunt de forma aBA→ sau aA → , unde
A şi B sunt simboluri neterminale iar a este simbol terminal.
Corespunzător claselor de gramatici, avem clase de limbaje. Astfel, se poate defini o ierarhie
Chomsky a limbajelor.
Limbajul independent de context (de tip 2) este modelul limbajelor de programare.
În ceea ce priveşte utilitatea studiului gramaticilor pentru scrierea compilatoarelor următoarea
teoremă este importantă:
Orice limbaj dependent de context (şi în consecinţă independent de context şi regulat) este
decidabil. Deci, limbajele de programare sunt decidabile.
1.1.2. Specificarea limbajelor de programare
1.1.2.1. BNF
Pentru descrierea limbajelor de programare se folosesc meta-limbaje. Acestea furnizează reguli
prin care se pot specifica şirurile acceptate de un limbaj de programare.
BNF (Backus Naur Form) reprezintă cel mai utilizat limbaj de specificare a limbajelor de
programare. În fond, BND descrie o gramatică independentă de context. În BNF, simbolurile
neterminare se scriu între paranteze unghiulare şi se definesc recursiv, prin meta-formule. De
asemenea, semnul ::= face parte din limbajul de specificare. El se poate traduce prin: „se
defineşte astfel”. Simbolul | înseamnă alegere, în sensul că permite utilizarea uneia din cele 2
alternative alăturate simbolului. Astfel, în BNF, simbolurile <, >, ::=, | se numesc meta-
simboluri.
Pentru a exemplifica utilizarea BNF, vom descrie în acest limbaj de specificare sintaxa de
compunere a unei propoziţii simple într-un limbaj informal. Astfel, avem următoarea definiţie
BNF:
<sentence>::=<subject><verb><object>.
<subject>::=<article><noun>|<subject pronoun>
<verb>::=sees|hits
<object>::=<article><noun>|<object pronoun>
<article>::=a|the
<subject pronoun>::=he|she
<object pronoun>::=him|her
În consecinţă, se constată faptul că o propoziţie este compusă prin alăturarea unui subiect, verb şi
a unui obiect (atribut). Propoziţia se termină cu simbolul terminal punct. Subiectul poate fi un
articol alăturat unui substantiv sau (simbolul |) un pronume de tip subiect. Pronumele de tip
subiect poate fi unul din simbolurile terminale he sau she etc.
Astfel, prin asemenea construcţii se pot descrie toate regulile de producţie din limbaj. De fapt,
prin BNF descriem întreaga gramatică pe baza căreia apoi, putem genera limbajul corespunzător
acesteia. Deci, pornind de la specificarea BNF a unei gramatici, putem folosi această specificare
pentru a genera limbajul şi pentru a recunoaşte sintagmele acceptate de gramatica definită.
Procesul prin care, având dată la intrare o propoziţie, determinăm (decidem) dacă propoziţia
respectivă este acceptabilă, în contextul unei gramatici se numeşte parsare.
1.1.2.2. EBNF
De multe ori, specificarea BNF este greoaie din punct de vedere al lizibilităţii, mai ales atunci
când un simbol neterminal se poate repeta, de un număr finit sau infinit de ori, într-o construcţie.
De exemplu, putem considera definirea unui string ca fiind un şir de una sau mai multe cifre:
<string>::=<digit>|<digit><string>
Pentru a uşura asemenea construcţii, EBNF introduce următoarele meta-simboluri:
{} tot ce e inclus între acolade se poate repeta, sau poate lipsi
[] tot ce e inclus între parantezele drepte poate lipsi (este opţional)
Astfel, construcţia de mai sus poate fi scrisă:
<string>::=<digit>{<digit>}
De exemplu, pentru a defini un număr, care poate avea simbolul de semn, putem folosi
următoarele construcţii alternative:
<numer>::=<sign><unsigned>|<unsigned> în BNF
sau
<number>=[<sign>]<unsigned> în EBNF
EBNF introduce şi alte meta-simboluri ajutătoare. Astfel avem:
* înlocuieşte acoladele, are semnificaţia că simbolul precedent se poate repeta de un număr
de 0 sau mai multe ori
+ simbolul precedent se poate repeta de 1 sau mai multe ori
_ se subliniază meta-simbolurile, atunci când acestea fac parte din alfabetul limbajului
specificat
1.1.2.3. Diagrame de sintaxă
Reprezintă o descriere alternativă vizuală, a specificării unui limbaj. Au fost introduse la
specificarea limbajului Pascal. Diagramele de sintaxă reprezintă grafe orientate având ca noduri
simbolurile din limbaj iar săgeţile indică succesiunea acceptată a acestora.
Să considerăm o gramatică specificată în limbaj EBNF.
<sentence>::=<expression>=
<expression>::=<number>[<operator><expression>]
<number>::=[<sign>]<unsigned>
<operator>::=*|+|-|/
<sign>::=+|-
<unsigned>::=<string>[.<string>]|.<string>
<string>::=<digit>{<digit>}
<digit>::=0|1|2|3|4|5|6|7|8|9
Figura 5 prezintă câteva diagrame de sintaxă pentru elementele gramaticii de mai sus.
<sentence> <expression> =
<digit> 0
1
2
3
4
5
6
7
8
9
<digit><string>
Figura 18.1 Diagrame de sintaxă
1.1.3. Automate de acceptare
Un automat de acceptare este folosit pentru a răspunde la întrebarea: un şir x aparţine limbajului
L sau nu?
Automatul este definit ca o maşină cu operaţii simple, care primeşte şirul de analizat pe un suport
oarecare, îl parcurge, şi răspunsul final este dat de starea în care rămâne unitatea de comandă a
automatului. Suportul pe care este furnizat şirul este denumit generic bandă de intrare, iar
variabila care parcurge şirul de analizat în citire se numeşte cap de citire. Automatul poate folosi
o memorie auxiliară pentru păstrarea unor informaţii care să fie de folos la un moment dat, în
procesul decizional.
Asemenea automate sunt definite prin grafe. Nodurile grafului reprezintă stări ale automatului, iar
arcele reprezintă tranziţii între stări. Arcele sunt marcate cu condiţii, cu semnificaţia că, dacă
automatul se află într-o anumită stare, şi se îndeplineşte condiţia de pe un arc care iese din starea
respectivă, atunci automatul va trece în starea de la celălalt capăt al arcului selectat.
Automatele pot fi:
- deterministe, dacă dintr-o stare se poate realiza cel mult o singură mişcare
- nedeterministe, dacă dintr-o stare există mai multe mişcări posibile. Automatele
nedeterministe corespund gramaticilor cu producţii cu alternative.
Un şir x este acceptat de un automat U dacă, pornind de la configuraţia iniţială, prin mişcările
automatului se parcurge întregul şir de intrare şi automatul ajunge într-o configuraţie finală.
Pentru exemplificare, vom considera automatul finit din figura 6.
q0 q1a
b
ba
Figura 18.2 Diagrama de tranziţii a unui automat finit cu 2 stări
Astfel, automatul din figura 18.2 are 2 stări: q0 şi q1. q0 reprezintă starea iniţială, q1 reprezintă
starea finală. Dacă, pe banda de intrare se întâlneşte şirul „a” şi automatul este în starea q0, atunci
automatul trece în starea q1. Dacă pe banda de intrare se întâlneşte şirul „b” şi automatul este în
starea q0, atunci automatul rămâne în aceeaşi stare etc. Tabelul 2 prezintă matricea de definire
corespunzătoare acestui automat.
a b
q0 q1 q0
q1 q1 q0
Tabelul 18.1 Matricea automatului din figura 6
Pentru acest automat, şirul „aaba” este un şir acceptat deoarece:
(q0,aaba) a (q1,aba) a (q1,ba) a (q0,a) a (q1,λ )
Deci, pornindu-se din starea iniţială q0, se parcurge şirul de intrare şi în final, la epuizarea
acestuia, se ajunge în starea finală q1, cu şirul vid λ .
Exemplul din figura 18.1 reprezintă un automat finit determinist. În figura 18.2 prezentăm un
exemplu de automat finit nedeterminist. Tabelul 3 reprezintă matricea de tranziţii pentru acest
automat. Caracteristic automatului nedeterminist este faptul că dintr-un nod pot ieşi mai multe
săgeţi etichetate cu acelaşi simbol de intrare, precum şi săgeţi etichetate cu λ care reprezintă
tranziţii independente de intrare.
0 1
2
3a b
b b
λ
a
a
Figura 18.2 Diagrama de tranziţie a unui automat finit nedeterminist
a b λ
0 {0,1} - {2}
1 - {2,3} -
2 {2} {3} -
3 - - -
Tabelul 18.2 Matricea automatului finit din figura 7
Se poate stabili o relaţie între automatele finite deterministe şi cele nedeterministe. Astfel, pentru
orice automat finit nedeterminist există un automat finit determinist care acceptă acelaşi limbaj.
Transformarea unui automat finit nedeterminist într-un automat finit determinist este importantă
deoarece lucrul cu automatele nedeterministe este dificil, datorită traiectoriilor paralele pe care le
poate lua calculul în aceste automate. Dar noi trebuie să privim aceste automate în contextul
studiului gramaticilor, care definesc limbajele de programare. Astfel, pornind de la o gramatică,
se poate genera un automat finit care să accepte gramatica respectivă. Dacă automatul finit
obţinut pentru o gramatică este nedeterminist, vom aplica procedura pentru construirea
automatului finit determinist echivalent.
Automatele deterministe ne indică modul în care trebuie să tratăm un şir de intrare pentru a
identifica dacă acesta este acceptat sau nu de automat. Astfel, la construirea compilatoarelor,
analizorul sintactic foloseşte logica automatului pentru a spune dacă o construcţie de intrare e
validă sau nu, şi în caz afirmativ, consideră mai departe această construcţie.
În practică, ori de câte ori avem de implementat un parser, adică un program care interpretează la
intrare şiruri de caractere cu anumite proprietăţi, prin descrierea automatului corespunzător putem
identifica traiectoriile posibile de intrare şi implementa un analizor corect şi eficient. În ANSI C
există biblioteca de expresii regulate folosite pentru generare de şabloane de şiruri de caractere,
care urmează exact regulile semantice de descriere ale automatelor.
1.2. Compilatoare
În procesul de comunicare om-calculator intervine un program intermediar, translatorul, care
asigură traducerea programelor scrise de utilizator din cod sursă într-un alt limbaj mai apropiat de
calculator. Dacă limbajul ţintă este codul maşină, translatorul se numeşte compilator. Astfel,
execuţia unui program sursă se realizează, în cadrul limbajelor compilative, în 2 faze:
- compilare, care traduce codul sursă în program obiect
- execuţie, care rulează codul obiect pe calculator, folosind datele iniţiale ale programului şi
produce rezultate
Compilatorul unui limbaj de asamblare se numeşte asamblor.
În practică, pe lângă compilatoarele obişnuite, există şi alte tipuri de compilatoare.
Astfel, preprocesoarele sunt translatoare care traduc dintr-un limbaj de nivel înalt în alt limbaj de
nivel înalt. Preprocesorul limbajului C++ reprezintă un bun exemplu.
Cross-compilatoarele sunt compilatoare scrise pentru un calculator gazdă, în vederea generării de
cod pentru alt calculator. Cross-compilatoarele sunt folosite la scrierea de cod pentru diverse
dispozitive inteligente, care conţin procesoare.
În cazul limbajelor interpretative, compilatorul este de tip special, adică incremental. Astfel,
programul sursă este spart de către compilator în porţiuni mici numite incremente, care au o
oarecare independenţă sintactică şi semantică. Incrementele sunt traduse de compilator. Pe
măsură ce compilatorul traduce un increment, calculatorul execută incrementul tradus.
În mod tradiţional, un compilator realizează un şir de transformări asupra codului sursă în
reprezentări din ce în ce mai apropiate de codul maşină. Figura 10 prezintă fazele unui
compilator.
Analizalexicala
Analizasintactica
Analizasemantica
Optimizarede cod
Generare decod
Tratareaerorilor
Gestiuneatabelelor
Programsursa
Sir de atomilexicali
Arboresintactic
Codintermediar
Cod intermediaroptimizat
Programobiect
Figura 18.3 Fazele unui compilator [Şerbănaţi 1987]
Analiza lexicală grupează caracterele din program în subşiruri numite atomi lexicali care
reprezintă cuvintele cheie, operatori, constante, identificatori şi delimitatori.
Şirul de atomi lexicali este preluat de analiza sintactică. Aceasta depistează structuri sintactice
cum ar fi expresii, liste, instrucţiuni, proceduri. Aceste structuri sunt plasate într-un arbore
sintactic conform relaţiilor existente între aceste structuri.
Analiza semantică foloseşte structura programului pentru extragerea informaţiilor privind
obiectele purtătoare de date (variabile, proceduri, funcţii), verificarea consistenţei utilizării lor. Pe
măsura parcurgerii arborelui sintactic, analiza semantică construieşte o reprezentare a codului
sursă în cod intermediar. Acesta este de obicei un şir de instrucţiuni simple cu format fix. Ordinea
operaţiilor din codul intermediar respectă ordinea de execuţie a acestora pe calculator.
Codul intermediar este prelucrat în faza de optimizare pentru eliminarea redundanţelor de calcule,
a calculelor şi variabilelor inutile, pentru o execuţie mai eficientă.
Generarea de cod alocă celule de memorie pentru memorarea datelor la execuţie. Se alocă
registre şi se produce cod obiect echivalent cu programul în limbaj intermediar.
Gestiunea tabelelor este de fapt, o colecţie de proceduri care creează şi actualizează datele cu care
lucrează celelalte faze. În această tabelă, pe lângă informaţii proprii compilatorului se găsesc şi
tabele ale identificatorilor, constantelor, cuvintelor cheie. Uneori avem o tabelă unică, numită
tabela simbolurilor.
Tratarea erorilor este o colecţie de proceduri care sunt activate ori de câte ori se depistează o
greşeală în program. De obicei, utilizatorul primeşte un mesaj de diagnostic. Dacă greşeala este
identificată în faza de analiză sintactică, compilatorul poate să-şi urmeze analiza pentru a detecta
şi alte erori.
Structura prezentată este mai mult conceptuală. Compilatoarele concrete de multe ori prezintă
abateri faţă de această structură. Unele componente pot lipsi, sau funcţionalitatea lor poate fi
preluată de alte componente, sau ordinea activării componentelor poate fi diferită.
Realizarea compilatoarelor presupune un volum mare de muncă. Există unelte software
specializate care asigură facilităţi de dezvoltare a compilatoarelor. Astfel, avem uneltele LEX şi
YACC sub Unix care permit descrierea sintactică şi semantică a unui compilator, conform
regulilor limbajului, utilizând automatele de acceptare a construcţiilor de intrare. Programatorul
trebuie să descrie regulile limbajului şi sintaxa propoziţiilor acceptate în format de expresii
regulate, iar mai apoi construcţia ţintă asociată fiecărei sintagme de limbaj sursă. Utilitarele
generează un cod C care apoi compilat, reprezintă de fapt compilatorul pentru limbajul sursă
considerat.
Datele de intrare pentru asemenea unelte sunt:
- specificaţia limbajului sursă, în ceea ce priveşte descrierea lexicului şi a sintaxei
- specificaţia limbajului ţintă şi a regulilor semantice de traducere
- specificaţia maşinii ţintă
Dacă în trecut, timpul de elaborare a unui compilator era destul de mare, azi, utilizând asemenea
tool-uri moderne putem realiza rapid compilatoare pentru diverse limbaje.
1.3. Întreb ări recapitulative
1. Descrieţi clasificarea gramaticilor după Naom Chomsky.
2. De ce limbajul independent de context reprezintă modelul unui limbaj de programare?
3. Cum se specifică un automat? Care sunt tipurile de automate? Explicaţi importanţa studierii
automatelor cu privire la studiul limbajelor de programare.
4. Explicaţi structura (fazele) unui compilator. Ce se întâmplă în fiecare fază?
5. Se dă următoare specificare EBNF pentru un calculator de buzunar: (a) <expression>::=<number>{<operator><expression>}
(b) <sentence>::=<number>{<operator><sentence>}=
(c) <number>::={<sin>}<unsigned>
Sunt aceste reguli de specificare corecte?
6. Fie următoarele producţii: <unsigned>::=<string>[.<string>]
<string>::=<digit>{<digit>}
<digit>::=0|1|2|3|4|5|6|7|8|9
Se decidă care din următoarele numere sunt acceptate sau nu de gramatica specificată mai sus:
(a) 1
(b) 1.
(c) 1.1
(d) .1
(e) 12.34
7. Să se scrie automatul finit determinist care acceptă următorul limbaj: {a, b, ab, abab, …}
8. Fie gramatica regulată G=<{B, S}, {a, b}, P, S> unde
},,,,,{ bBaBbBBaBBaBSSP →→→→→→= λ . Descrieţi limbajul aferent acestei
gramatici în BNF. Trasaţi automatul finit care acceptă această limbajul asociat gramaticii.
Aplicaţi procedura de conversie a automatului nedeterminist în automat finit determinist şi
listaţi automatul determinist echivalent.
MODULUL 2. INTRODUCERE IN PROGRAMAREA OBIECTUAL Ă
Scopul si obiectivele modulului
In acest modul urmează să prezentăm descrierea, realizarea si implementarea conceptelor
de programare obiectuală in C++. Aceste concepte sunt detaliate in curs [Silaghi 2008].
2.1. Paradigma programării obiectuale
Toate limbajele de programare realizează abstractizări. Atunci când scrie un program,
programatorul trebuie să gândească în limbajul înţeles de calculator pentru a rezolva o
problemă. Se stabileşte astfel o corespondenţă între modelul maşinii (în spaţiul soluţiilor)
pe care se rulează programul şi modelul problemei care se rezolvă prin intermediul
calculatorului.
Activitatea de programare presupune stabilirea acestei asociaţii pentru o problemă cerută
a se rezolva pe calculator. Pentru simplificarea acestei activităţi de programare, este
necesară inventarea unor limbaje de programare pentru care modelele maşinii (spaţiul
soluţiilor) să fie cât mai aproape de percepţia umană despre spaţiul problemei de rezolvat.
Programarea obiectuală asociază elementelor din spaţiul problemei obiecte în spaţiul
soluţiilor.
Principalele caracteristici ale unui limbaj de programare obiectual sunt:
1. Orice element este un obiect. Putem vedea obiectele ca şi variabile care memorează
date, dar în plus, putem adresa cereri obiectelor, solicitându-le să-şi schimbe starea.
2. Un program este o colecţie de obiecte. Obiectele sunt legate unele de altele,
transmiţându-şi mesaje. Putem vedea un mesaj ca şi un apel de funcţie.
3. Fiecare obiect are propriul spaţiu de memorie, şi este constituit din alte obiecte.
Astfel, se pot crea noi tipuri de obiecte împachetând obiecte existente.
4. Fiecare obiect are un tip.
5. Toate obiectele dintr-un anumit tip pot primi acelaşi mesaj.
Nume tip Ligth
Interfaţa on()
off()
Figura 19.1 Exemplu simplu de clasă
O clasă descrie un set de obiecte care au caracteristici şi funcţionalitate identice. Clasa
este un tip de date, aşa cum sunt cunoscute tipurile de date în C. Cererile care pot fi
adresate unui obiect definesc interfaţa obiectului. Funcţiile care compun interfaţa unei
clase se numesc metode. Figura 19.1 prezintă exemplul clasei Ligth.
Astfel, dacă lt este un obiect de tipul Ligth, atunci putem cere acestui obiect realizarea
mesajului on():
Implementarea constă în modalitatea concretă prin care se realizează funcţionalitatea
descrisă de interfaţă. Astfel, în exemplul prezentat anterior, implementarea va consta în
acţiunile concrete care trebuiesc realizate (programate) pentru a realiza operaţia on().
În activitatea de scriere şi utilizare a programelor informatice, se disting 2 tipuri de actori:
- programatori de clase noi
- programatori care folosesc clasele create de creatorii de clase. Programele scrise de
aceştia se numesc programe client.
Scopul programatorilor de aplicatii client este de a colecta şi utiliza un mediu de lucru
care să conţină cat mai multe clase, pentru a asigura dezvoltarea rapidă aplicaţiilor.
Scopul programatorilor creatori de clase este de a construi clase care să furnizeze
programatorilor clienţi doar ceea ce este necesar, restul componentelor clasei urmând să
rămână ascunse.
Se ajunge astfel la conceptul de “ascundere a implementării ”, descris în C++ prin
cuvintele cheie public, private, protected. Aceste cuvinte cheie se numesc modificatori de
acces.
După ce o clasă a fost creată, testată şi începe să fie utilizată în programe client, se pot
construi noi clase care să conţină obiecte din clasa iniţială. Astfel, se realizează “re-
utilizarea implementării” , una din facilităţile principale oferite de programarea
obiectuală.
Procesul prin care se compune o nouă clasă înglobând de la clase existente se numeşte
compoziţie1 (agregare).
Dacă avem creată o clasă, şi dorim să creăm o clasă nouă, cu o funcţionalitate similară cu
a clasei iniţiale (o clasă care, eventual, să conţină aceeaşi funcţionalitate a clasei iniţiale,
extinsă cu proprietăţi noi), se foloseşte conceptul de moştenire. Clasa iniţială de la care
porneşte procesul de moştenire se numeşte clasă de bază.
În cazul în care prin moştenire, clasa nouă are exact aceeaşi interfaţă ca şi clasa de bază,
având doar o funcţionalitate diferită pentru metodele din interfaţă, spunem ca obiectele
din tipul clasei derivate sunt de tipul clasei de bază. Un asemenea tip de relaţie este
denumită generic relaţie de tip is-a.
1 Composition în lb. Engleză
Dacă insă, clasa derivată adaugă funcţionalitate nouă prin crearea de noi metode în
interfaţă, atunci spunem că obiectele de tipul clasei derivate se aseamănă cu obiectele din
clasa de bază. Relaţia nou creată se numeşte generic relaţie de tipul is-like-a.
Prin moştenire, se pot crea ierarhii de clase. Între obiectele de tipul claselor din ierarhie
se pot stabili relaţii de tipul is-a sau is-like-a. Astfel, putem considera că obiectele din
clasele derivate sunt în acelaşi timp şi obiecte din tipul clasei de bază. Programarea
orientată obiect ne permite să utilizăm obiecte de tipul claselor derivate în locul
obiectelor de tipul clasei de bază. Se obţine astfel posibilitatea schimbării obiectelor din
clase diferite între ele, realizându-se polimorfism prin interschimbare de obiecte. Prin
polimorfism, compilatorul C++ va determina în momentul execuţiei tipul din care face
parte obiectul, apelând funcţionalitatea aferentă metodei solicitate.
Crearea şi distrugerea obiectelor reprezintă elemente importante de care trebuie să se
ţină seama la scrierea programelor. La crearea obiectelor, memoria care se alocă acestora
poate fi
- determinată la compilare. Variabilele (obiectele) declarate din cadrul programului
sunt de acest tip. Acestea sunt
- obiecte globale: se aloca în zona statică de memorie a programului
- obiecte cu vizibilitate locală: se alocă în zona de stivă a programului
- determinată la execuţie. Variabilele (obiectele) create dinamic sunt de acest tip.
Pentru acest tip de obiecte necesarul de memorie nu se poate determina în momentul
compilării. Aceste obiecte sunt alocate în zona de “heap” a programului. Pentru
crearea dinamică a unui asemenea obiect în C++ se foloseşte cuvântul cheie new iar
pentru distrugere delete.
Pentru obiectele alocate static sau pe stivă, compilatorul se ocupă de distrugerea acestora
la terminarea programului sau a unei zone de vizibilitate. Pentru obiectele dinamice,
programatorul trebuie să se ocupe de distrugerea acestora.
Unele limbaje obiectuale (ex. Java) au un mecanism prin care mediul de execuţie caută şi
identifică obiectele alocate dinamic care nu mai sunt utilizate în program, şi la
identificarea lor, le dezalocă. Mecanismul care realizează această caracteristică se
numeşte “garbage collector” şi degrevează programatorul de sarcina de a mai distruge
obiectele.
De multe ori, la execuţia programelor, pot apărea situaţii excepţionale, care nu au fost
prevăzute la scrierea programului şi care generează erori. Cu cât programatorul este mai
vigilent la scrierea programelor, cu atât mai mult scade probabilitatea ca programul
realizat să furnizeze erori. Unele limbaje avansate de programare furnizează mecanisme
de evitare a erorilor, numite “exception handling”. Astfel, în momentul în care apare o
eroare, se generează un obiect numit excepţie care urmează să fie tratat în mod special de
program într-un fir alternativ de execuţie.
2.2. Analiza şi designul programelor orientate obiect
Paradigma programării obiectuale diferă substanţial de tipurile de programare anterioară,
în sensul că programele trebuie gândite într-un mod diferit. Există o metodă de realizare a
programelor orientate obiect. Metoda (metodologia) conţine un set de procese şi euristici
prin care se “sparge” complexitatea unei probleme de programare obiectuală.
În programarea obiectuală s-au dezvoltat multe metode orientate obiect de dezvoltare a
programelor. Înainte de a considera o asemenea metodă pentru rezolvarea unei probleme,
trebuie să se înţeleagă tipul problemei pentru rezolvarea căreia este destinată metoda.
Când citim şi dorim să înţelegem o metodă orientată obiect, trebuie să identificăm
răspunsul următoarelor întrebări:
- Care sunt obiectele?
- Care este interfaţa acestor obiecte?
Dacă se identifică obiectele şi interfeţele acestora, se poate începe cu scrierea
programelor.
Astfel, conform metodologiei obiectuale, procesul de realizare a programelor poate fi
descris în 5 paşi:
- Pasul 0: Se alege un plan care va fi urmat la rezolvarea problemei. Trebuie să se
decidă care sunt paşii pe care procesul analizat în conţine. Orice program se scrie cu
un scop precis. Trebuie să se identifice de ce este necesar programul, care sunt
obiectivele pe care acesta trebuie să le realizeze.
- Pasul 1: Se identifică problema de rezolvat. Acest pas corespunde cu scrierea
specificaţiilor programului. Trebuie identificat ceea ce urmează să realizeze
programul. Pentru descrierea funcţionalităţii programului, putem folosi diagramele
cazurilor de utilizare (use-case ). Acestea descriu răspunsul la următoarele întrebări:
- cine va folosi programul
- care sunt actorii din sistem
- ce fac aceşti actori în (cu) sistem(ul)
- cum ar evolua sistemul dacă actorii din sistem ar avea alte obiective decât cele
specifice problemei studiate
- ce probleme ar putea să apară (cazuri în care sistemul ar furniza erori)
- Pasul 2: se răspunde la întrebarea: cum va fi construit sistemul? Acest pas furnizează
proiectarea claselor, precum şi a interacţiunilor dintre ele. Pentru fiecare clasa trebuie
identificate:
- numele clasei
- responsabilitatea clasei: ce funcţionalitate are clasa
- “colaborările” clasei: alte clase cu care clasa sub studiu interacţionează
- Pasul 3: se realizează nucleul sistemului. Presupune transcrierea designului furnizat
în pasul precedent în cod sursă, compilarea şi executarea programului. În acest pas
este important să se furnizeze un program funcţional, care se execută, chiar dacă acest
program este incomplet, nu realizează toate cerinţele problemei.
- Pasul 4: iterarea cazurilor de utilizare. Când avem un nucleu funcţional, urmează să
adăugam la acest nucleu funcţionalităţi din cazurile de utilizare a programului.
Fiecare nouă funcţionalitate adăugată programului constituie o iterare a programului,
respectiv o nouă versiune a aplicaţiei. Lungimea unei iteraţii depinde de
complexitatea aplicaţiei care se realizează. Se ajunge la sfârşitul iteraţiilor atunci când
se realizează toate cerinţele descrise în diagramele use-case, respectiv atunci când se
ajunge la termenul limită de livrare a programului sau când programul este acceptat
de client.
- Pasul 5. Utilizarea programului. În această fază se observă modul în care programul
răspunde utilizării curente. Se asigură mentenanţă programului, rezolvându-se
eventuale buguri (erori semnalate).
Analiza şi designul programelor orientate obiect reprezintă conţinutul principal al
disciplinelor din categoria “software engineering” (ingineria programării şi proiectare
obiectuală). În cadrul acestor discipline s-au dezvoltat metodologii concrete de lucru în
medii obiectuale. Intre acestea amintim OMT2 şi RUP3
În secţiunea următoare vom descrie stilul de programare „extreme programming” (XP),
având in vedere faptul că acesta permite o organizare eficientă a lucrului in echipe cu
puţini programatori, pe proiecte de dimensiuni rezonabile.
2.2.1. eXtreme Programming
eXtreme Programming (XP) reprezintă o filosofie alternativă pentru scrierea
programelor, respectiv un set de reguli pentru analiza şi realizarea programelor. Autorii
care au propus această alternativă o argumentează prin obţinerea unui câştig de 2 Object Management Technology, standardizată de OMG 3 Rational Unified Process, metodologie dezvoltată de Rational Software şi preluată de IBM
productivitate respectiv de fiabilitate a activităţii de programare. XP se bazează pe 2
principii:
1. Write tests first: I mediat ce se obţine o bucată de cod care funcţionează,
programatorul trebuie să o testeze, pentru a se obţine siguranţa corectitudinii codului
respectiv. Adoptarea acestei tehnici presupune scrierea de programe de test pentru
fracţiuni de cod, înainte ca aceste bucăţi de cod să fie integrate în programul
principal. Aceasta are 2 consecinţe esenţiale:
- forţează o definire clară a interfeţei obiectelor
- testele sunt rulate ori de câte ori se realizează integrarea programelor.
2. Pair programming. Programarea trebuie realizată cu 2 programatori pe staţie de
lucru: una din persoane scrie codul iar cealaltă se gândeşte la cum trebuie să arate
codul. De obicei, cel care gândeşte păstrează întotdeauna o vedere de ansamblu
asupra aplicaţiei care trebuie realizată, nu numai asupra piesei de cod care se scrie la
un moment dat. “Pair programming” este utilizat în firmele de software prin
asigurarea unui analist sau a unui “senior programmer” la o echipa de programatori.
Programatorul senior are viziune asupra aplicaţiei scrise, cunoaşte detaliile de design
ale aplicaţiei, şi intră în detaliile de implementare doar atunci când un programator
membru al echipei nu poate realiza o anume funcţionalitate.
Recomandăm utilizarea acestui stil de programare în activitatea de învăţare a programării
obiectuale. Astfel, totdeauna, programatorii vor putea să se corecteze unul pe celălalt. Se
va asigura astfel deprinderea mai rapidă a cunoştinţelor necesare programării, respectiv
corectitudinea codului rezultat.
2.3. Formalizarea conceptului de obiect
În C, o structură reprezintă o aglomerare de date, un mod de a împacheta mai multe
variabile pentru o utilizare comună a lor. În C++, un obiect este o variabilă, adică un
spaţiu de memorie care are un identificator unic, care păstrează date şi specifică operaţiile
care se pot executa asupra acestor date.
Se numeşte încapsulare abilitatea de a împacheta împreună date cu funcţii, în vederea
creării de noi tipuri de date. Astfel, prin definiţia lui CppVector s-a creat un nou tip de
date. Ne referim la tipurile de date noi create prin sintagma de “tipuri abstracte de date”
deoarece ele permit abstractizarea unor concepte din spaţiul problemei de rezolvat.
Sintagma
obiect.functieMembra(listaArgumente)
înseamnă pentru compilator apelarea funcţiei membru pentru un obiect, iar în
terminologie orientată obiect înseamnă transmiterea unui mesaj către obiectul respectiv.
Astfel, un program în C++ înseamnă creare de obiecte şi transmitere de mesaje către
acestea.
Mărimea unei structuri este egală cu mărimea adunată a tuturor membrilor structurii.
Uneori, compilatorul adaugă câţiva octeţi pentru a stoca informaţii adiţionale despre
obiecte. Mărimea unui obiect poate fi identificată prin sizeof.
La crearea de noi tipuri de date se doreşte separarea interfeţei de implementare. Astfel,
declararea tipului de date se va realiza în fişiere header, iar implementarea (definirea
funcţiilor) în fişiere cpp. Astfel, în programe mai complexe, vor putea schimba
implementarea specifică anumitor tipuri de date fără a fi nevoiţi să schimbăm restul
sistemului. Fişierele header sunt obligatorii pentru dezvoltarea programelor în C++.
Fişierele header realizează contactul între programatorii de biblioteci şi utilizatorii
acestora.
În acest sens, putem enunţa o definiţie informală de care se ţine cont în activitatea de
programare obiectuală:
C++ one definition rule: pentru fiecare obiect C++ este permisa o singură definiţie.
Astfel, în fişierele header trebuie plasate doar declaraţii, în timp ce definiţiile se vor plasa
în fişierele cpp.
2.4. Ascunderea implementării
Programarea orientată obiect introduce controlul accesului la membrii unei structuri.
Acesta este necesar deoarece:
- programatorul client nu trebuie să poată accesa funcţionalităţi care nu îi sunt destinate
(cum ar fi manipulările interne structurii)
- programatorul bibliotecii de obiecte trebuie să poată schimba implementarea internă
fără să afecteze eventualele programe client scrise deja, care utilizează biblioteca
2.4.1. Specificatori de acces
În C++ specificatorii de acces sunt utilizaţi în declaraţii de structuri, şi sunt urmaţi de :.
Aceştia sunt:
- public: declaraţiile care urmează acestui specificator sunt disponibile pentru oricine.
Membrii public sunt identici cu membrii necalificaţi ai unei structuri.
- private: declaraţiile care urmează acestui specificator sunt disponibile doar în
funcţiile membre ale tipului respectiv. Dacă se încearcă să se acceseze un membru
private, compilatorul generează eroare.
- protected: este similar cu private; are semnificaţie doar în contextul utilizării
moştenirii. Astfel, clasele care moştenesc din tipul în discuţie au acces la membrii
protected. Membrii protected nu se văd în afara ierarhiei de moştenire.
Specificatorii pot să apară în orice număr şi ordine în cadrul unei structuri (clase). Aceşti
specificatori de access au rolul de a ascunde implementările de interfeţe, şi de a asigura
un control eficient şi o separare a datelor şi conceptelor din program.
2.4.2. Încapsulare
Controlul accesului mai este denumit şi “ascunderea implementării”. Încapsularea şi
controlul accesului conduce la definirea unor entităţi care sunt mai mult decât simple
structuri C. Astfel, prin încapsulare, putem cuprinde împreună date (proprietăţi) şi funcţii
(comportament). Prin controlul accesului putem separa interfaţa de implementare. Astfel,
se poate schimba în orice moment implementarea fără a afecta funcţionalitatea
programelor care utilizează variabile de tipul respectiv (ele interacţionează doar prin
intermediul interfeţei). Astfel, apare noţiunea de clasă, definită în C++ prin cuvântul
cheie class. O clasă este o structură, cu menţiunea că toţi membrii acesteia sunt implicit
membrii privaţi, spre deosebire de struct unde implicit toţi membrii sunt publici.
2.5. Construirea şi distrugerea obiectelor. Supraîncărcare
2.5.1. Constructori şi destructor
Un număr mare de erori C provin din faptul că programatorii uită să iniţializeze
variabilele, respectiv să dezaloce memoria la sfârşitul execuţiei programului. Aceste erori
sunt amplificate la utilizarea bibliotecilor, când programatorii clienţi nu ştiu cum să
iniţializeze datele din structuri, fie din ignoranţă, fie din lipsa unor documentaţii adecvate
a bibliotecilor. Constructorii vin să rezolve această problemă
Dacă o clasă are un constructor, compilatorul invocă în mod automat constructorul la
crearea obiectului. Constructorul este o funcţie membră a clasei care are acelaşi nume ca
şi clasa. Astfel, la definirea clasei avem următoarea declaraţie de funcţie membru:
Ca şi orice altă funcţie, constructorul poate primi argumente, şi anume, elemente care să
controleze modul în care este iniţializat obiectul. În plus, se pot scrie mai mulţi
constructori şi apelul acestora se poate realiza în mai multe modalităţi.
Constructorii (şi destructorii) nu au tip de return. Aceasta înseamnă că tipul de return
diferă de void (care înseamnă că funcţia nu returnează nimic). Dacă constructorii ar avea
tip de return (fie şi void) atunci ar fi nevoie ca să se realizeze apelul explicit al acestora,
deci constructorii nu s-ar mai putea apela implicit.
Destructorii sunt funcţii speciale membre ale claselor care se execută pentru a realiza
“ştergerea” (distrugerea) obiectelor. Numele destructorului este identic cu numele
claselor, fiind precedat de particula ~. Destructorii nu au argumente şi nici tip de return.
Pentru variabilele alocate pe stivă, destructorul este apelat automat când obiectul iese din
domeniul de vizibilitate. Aceasta se întâmplă chiar şi atunci când se foloseşte saltul
automat (goto) pentru a se ieşi dintr-un domeniu de vizibilitate.
Pentru variabilele dinamice, alocate in zona de heap, destructorul este apelat la
distrugerea acestor variabile prin operatorul delete.
În C, variabilele trebuiau definite întotdeauna la începutul blocului de vizibilitate (de ex.
la începutul funcţiilor). Această regulă nu se mai păstrează în C++. Astfel, obiectele (şi
variabilele) pot fi definite oriunde în cod şi definirea acestora se face de regulă, cât mai
aproape de locul de utilizare.
În C++, nu se permite crearea unui obiect până când constructorul acestuia nu va avea
disponibile toate informaţiile pentru apelul său (argumentele constructorului trebuie să fie
definite şi iniţializate).
Cu toate că putem defini variabile în orice loc în cod, alocarea memoriei pentru
variabilele respective se realizează la începutul domeniului de vizibilitate în care apare
variabila. Variabila respectivă va fi disponibilă pentru utilizare doar după ce se întâlneşte
locul de definiţie, când se realizează şi apelul constructorului. Compilatorul va verifica
inclusiv faptul ca definiţia obiectului trebuie să se realizeze, deci constructorul trebuie să
se poată apela la locul de definire al obiectului.
Se numeşte constructor implicit (default), constructorul care nu are argumente.
În cazul în care clasa are constructor, compilatorul va forţa crearea obiectelor din clasa
respectivă prin intermediul constructorului. In acest caz, este absolute necesar să se
folosească in mod explicit constructorii la crearea obiectelor.
Dacă clasa nu are nici un constructor, atunci compilatorul va scrie (crea) automat un
constructor implicit pentru clasa respectivă. Cu toate că se creează în mod automat un
constructor implicit pentru obiect (în cazul în care clasa nu are constructori),
comportamentul acestui constructor este nedefinit. În consecinţă, se recomandă scrierea
de constructori pentru clase, şi în mod special, definirea explicită a constructorului
implicit.
C++ garantează crearea obiectelor la nivelul unei unităţi de compilare (funcţie), în
ordinea în care acestea sunt scrie. Distrugerea obiectelor se realizează în ordine inversă
creării.
Constructorul de copiere este constructorul clasei care primeşte ca şi argument o referinţă
la un obiect din tipul clasei respective. Astfel, constructorul de copiere creează obiectul
pe baza unui alt obiect de acelaşi tip.
Constructorul de copiere este esenţial la transmiterea prin valoare a obiectelor din tipuri
(clase) definite de utilizator. În cazul în care programatorul nu scrie un constructor de
copiere în clasă, compilatorul va genera în mod automat un asemenea constructor.
La transmiterea prin valoare a unui obiect unei funcţii , funcţiei îi este transmisă o copie a
obiectului. Copia obiectului este obţinută prin apelarea constructorului de copiere În
cazul în care obiectul este de dimensiune mare, procesul de creare a copiilor transmise,
respectiv de returnare a unor asemenea obiecte din funcţii devine costisitor (din punct de
vedere al timpului de execuţie şi al memoriei consumate).
Constructorul de copiere este utilizat în special la transmiterea prin valoare. O alternativă
la constructorul de copiere este evitarea transmiterii prin valoare. Pentru a se evita
transmiterea prin valoare, constructorul de copiere trebuie declarat ca şi membru privat al
clasei. În acest mod doar funcţiile care sunt declarate friend în clasa respectivă vor putea
realiza transmiterea prin valoare.
În cazul în care nu există nici o funcţie care realizează transmitere prin valoare, definiţia
funcţiei membru constructor de copiere poate lipsi.
O altă modalitate de evitare a constructorului de copiere este înlocuirea transmiterii prin
valoare prin transmiterea de referinţe constante
2.5.2. Supraîncărcarea funcţiilor
2.5.2.1. Conceptul de supraîncărcare
Funcţia reprezintă un nume asociat unei acţiuni (sau unui sir de acţiuni). De multe ori
apare o problemă la modelarea spaţiului problemei, când acelaşi nume are semnificaţii
diferite, în funcţie de contextul de apel. Conceptul are mai multe înţelesuri, este deci
“supraîncărcat”.
În multe limbaje de programare (inclusiv C) se solicită utilizarea de nume diferite pentru
sensuri diferite, sau în altă formulare, nume unice pentru fiecare înţeles distinct. În C++,
această regulă nu se mai păstrează. Un exemplu de încălcare a ei este utilizarea aceluiaşi
identificator pentru numele clasei şi numele constructorului.
Supraîncărcarea funcţiilor permite folosirea aceluiaşi identificator pentru a denumi mai
multe funcţii. La supraîncărcarea funcţiilor, se poate folosi acelaşi nume, dar lista
argumentelor trebuie să difere.
Nu se poate realiza supraîncărcarea funcţiilor numai pe tipul returnat.
Deoarece constructorii sunt la rândul lor funcţii, ei se pot supraîncărca. Astfel, se pot
scrie mai mulţi constructori pentru o clasă.
2.5.2.2. Supraîncărcarea operatorilor
Supraîncărcarea operatorilor reprezintă o nouă modalitate de apel a funcţiilor, existând o
diferenţă doar în ceea ce priveşte modul de scriere a apelului. Astfel, argumentele
funcţiilor nu mai apar între paranteze rotunde ci se scriu în jurul unor operatori, la fel ca
la scrierea unor expresii care conţin operatori.
C++ permite definirea unor operatori care să funcţioneze în conjuncţie cu clasele.
Definirea acestor operatori este similară cu definirea unor funcţii membre. Pe lângă
modalitatea de suprascriere a operatorilor prin funcţii membre, există şi posibilitatea de a
defini operatorii ca funcţii globale.
Definirea unui operator supraîncărcat este similară cu definirea unei funcţii membru, cu
diferenţa că numele funcţiei este operator@ unde @ reprezintă operatorul care se
supraîncarcă.
Supraîncărcarea se poate realiza şi prin funcţii globale. În acest caz, funcţia
supraîncărcată poate fi declarată ca şi friend în clasa pentru care se realizează
supraîncărcarea.
La supraîncărcarea operatorilor se aplică următoarea regulă:
- În cazul în care se supraîncarcă un operator ca şi funcţie membru şi operatorul este
binar, atunci funcţia supraîncărcată are un singur argument.
- În cazul în care se supraîncarcă un operator ca şi funcţie membru şi operatorul este
unar, atunci funcţia supraîncărcată nu are argumente.
- În cazul în care se supraîncarcă un operator ca şi funcţie globală şi operatorul este
binar, atunci funcţia supraîncărcată are 2 argumente.
- În cazul în care se supraîncarcă un operator ca şi funcţie globală şi operatorul este
unar, atunci funcţia supraîncărcată are un singur argument.
La supraîncărcarea operatorilor, trebuie să se ţină cont de următoarele directive:
- nu se pot supraîncărca operatori care nu au sens în C (de exemplu operatorul ** - nu
există în C++)
- prin supraincărcare nu se poate schimba precedenţa operatorilor. Aceasta rămâne cea
definită de limbajul C++
- prin supraîncărcare nu se poate schimba numărul operanzilor ceruţi de un operator.
Astfel, un operator C++ binar nu poate fi rescris ca şi un operator unar.
Se prezintă următoarele caracteristici ale supraîncărcării operatorilor în C++:
- operatorul ++ poate fi apelat atât în forma prefixată cât şi în forma postfixată. Pentru
a supraîncărca operatorul ++ în forma prefixată, ca şi funcţie membru, se scrie funcţia
în mod obişnuit. Pentru a supraîncărca ++ ca şi operator postfixat, se scrie funcţia
membru având o un argument “dummy” (care nu se foloseşte niciodată). Astfel, cele
2 funcţii vor avea semnături diferite şi compilatorul poate să discearnă între ele.
- pentru a supraîncărca operatorul ++ în forma prefixată, ca şi funcţie globală, se scrie o
funcţie globală obişnuită, cu un singur argument. Pentru a supraîncărca operatorul ++
în forma postfixată ca şi funcţie globală, se scrie funcţia globală având un argument
“dummy”. Astfel, cele 2 funcţii vor avea semnături diferite şi compilatorul poate să
discearnă între ele.
- regulile de mai sus se aplică pentru supraîncărcarea oricăror operatori unari, la care
există forme postfixate şi prefixate.
Operatorul de atribuire poate fi suprascris doar ca şi funcţie membră. La operatorul de
atribuire se recomandă verificarea realizării atribuirii între aceleaşi 2 obiecte (a=a).
Atribuirea între aceleaşi 2 obiecte de obicei nu are sens.
Următoarele reguli se aplică la transmiterea argumentelor şi returnarea valorilor:
- dacă operatorul supraîncărcat doar citeşte valorile operanzilor, argumentele se vor
transmite prin referinţe (&) constante (ex. operatorul +). În cazul în care valoarea
unui operand se modifică, acel operand nu va mai fi transmis prin referinţă constantă.
- tipul valorii returnate depinde de semnificaţia operatorului. Dacă operatorul produce
o valoare nouă, atunci aceasta trebuie returnată prin valoare (şi nu prin referinţă).
Astfel, la operatorul + se returnează un Integer constant (creat prin apelarea
constructorului).
- operatorii de atribuire trebuie să poată permite modificarea valorii returnate (pentru a
permite a=b=c). Astfel, se vor returna referinţe (neconstante). Referinţele neconstante
permit utilizarea rezultatului atribuirii ca şi operanzi stânga în expresii (ex.
(a=b).func(); )
- operatorii booleeni vor returna valori int şi nu bool
Din considerente de siguranţă, pentru a nu încălca principiile de verificării de tip impuse
de C++, unii operatori nu se pot supraîncărca. Regulile următoare se aplică în ceea ce
priveşte restricţiile la supraîncărcarea operatorilor:
- nu se poate supraîncărca operatorul . Dacă operatorul . ar putea fi supraîncărcat, nu
ar mai exista posibilitatea de selecţie a membrilor unei clase.
- nu se poate supraîncărca operatorul .*
- nu există operator pentru operaţia exponent.
- nu se pot inventa noi operatori faţă de cei pe care C ii pune la dispoziţie
prin supraîncărcare nu se poate schimba precedenta operatorilor
2.5.2.3. Considerente de design la supraîncărcarea operatorilor
La designul claselor, apare întrebarea referitoare la modul în care trebuie supraîncărcaţi
operatorii, adică la alegerea între supraîncărcarea operatorilor prin funcţii membre sau
prin funcţii globale.
De obicei, dacă nu există vreo cerinţă expresă, se alege supraîncărcarea prin funcţie
membră. Supraîncărcarea operatorilor prin funcţii membre accentuează asocierea dintre
obiect şi operatorul care i se aplică acestuia. Când obiectul apare ca şi operand stânga în
expresie, relativ la operator, atunci această abordare e cea mai potrivită.
Apar cazuri când operandul din stânga este dintr-o altă clasă decât cea în care ar trebui
supraîncărcat operatorul. În acest caz, cel mai uzual este utilizarea funcţiilor globale
pentru suprascrierea operatorilor.
2.5.2.4. Supraîncărcarea operatorului de atribuire
Operatorul de atribuire este un operator special care trebuie tratat special. Operatorul de
atribuire este cumva similar constructorului de copiere. Astfel, el realizează copierea
conţinutului obiectului din dreapta în zona de memorie a obiectului din stânga atribuirii.
Operatorul de atribuire trebuie suprascris ca şi funcţie membră. În cadrul funcţiei, trebuie
copiat conţinutul obiectului din dreapta (cel transmis ca şi argument) în obiectul din
stânga (cel asupra căruia se realizează apelul funcţiei). La suprascrierea operatorului de
atribuire trebuie eliminat cazul de auto-atribuire. Aceasta se realizează prin inserarea unei
verificări înainte de realizarea copierii membrilor.
Realizarea copierii membrilor e mai dificilă atunci când obiectele conţin pointeri. Simpla
copiere a valorii pointerilor ar face ca acelaşi obiect membru să fie referit de 2 pointeri.
Distrugerea unuia dintre cele 2 obiecte ar realiza dezalocarea obiectelor referite, şi ar
introduce buguri în program. Acest comportament a putut fi observat şi în cazul în care
constructorul de copiere nu ţine cont de acest lucru.
În concluzie, se impune o abordare diferită în cazul atribuirii între obiecte care agregă
indirect (prin pointeri) alte obiecte.
O primă abordare presupune copierea a tot ce se află în spatele pointerilor membrii., în
zonele de memorie aferente obiectului din stânga atribuirii. Această abordare trebuie
considerată atât pentru operatorul de atribuire cât şi pentru constructorul de copiere.
Constructorul de copiere şi operatorul de atribuire duplică obiectul din spatele pointerului
membru. Din momentul apelării constructorului de copiere sau a operatorului de atribuire
cele 2 obiecte sunt automat separate unul de celălalt.
A doua abordare care poate fi considerată la scrierea constructorului de copiere sau la
supraîncărcarea operatorului de atribuire în cazul obiectelor cu pointeri este numărarea
referinţelor. Aceasta se aplică atunci când obiectele nou create necesită un spaţiu de
memorie mare sau când crearea de noi obiecte este dificil ă şi costisitoare.
Numărarea referinţelor se realizează la nivelul obiectelor agregate indirect (prin pointeri).
Acestea vor conţine un contor care va număra de câte ori e referit obiectul (de către alţi
pointeri). La fiecare ataşare de pointer către obiectul respectiv (adică apel de constructor
de copiere sau asignare în clasa superioară), contorul se va incrementa; la fiecare
distrugere de pointer (apelare de destructor pentru clasa superioară), contorul se va
decrementa. În acest caz, acelaşi obiect va fi referit de pointeri aparţinând de obiecte
diferite.
Problema care apare în această abordare este la scrierea de date în obiectul referit. Dacă
contorul obiectului referit indică o valoare mai mare ca şi 1, înseamnă că mai multe
obiecte referă obiectul în cauză iar scrierea va modifica datele altui obiect. Astfel,
scrierea va fi permisă doar în cazul în care obiectul agregat este referit doar o singură
dată. În cazul în care dorim să scriem şi în cazul general (când numărul de referiri este
mai mare decât 1), atunci va trebui să duplicăm obiectul referit, şi să facem scrierea doar
pe copia nou creată.
Similar ca şi la constructorul de copiere, în cazul în care programatorul nu supraîncarcă
operatorul de atribuire compilatorul va genera un astfel de operator în mod implicit.
Operatorul de atribuire creat implicit are acelaşi comportament ca şi constructorul de
copiere creat implicit. La apelarea operatorului de atribuire creat implicit, se asigură
apelarea operatorilor de atribuire pentru membrii clasei respective.
2.6. Gestiunea dinamică a memoriei
2.6.1. Crearea obiectelor
La crearea unui obiect C++, se realizează următoarele acţiuni:
1. se alocă memorie pentru obiect
2. se apelează un constructor pentru iniţializarea zonei de memorie alocate în pasul 1.
Aceste 2 acţiuni se realizează întotdeauna la crearea obiectelor în C++. Alocarea
memoriei se poate realiza în următoarele moduri:
1. zona de memorie poate fi alocată înainte de începerea execuţiei programului. Aceste
zone se alocă în zona statică de memorie, şi ele există pe toată durata execuţiei
programului.
2. zona de memorie poate fi alocată pe stivă, în momentul în care execuţia programului
ajunge la un început de domeniu de vizibilitate. Această zonă alocată este automat
eliberată în momentul in care programul ajunge la sfârşitul domeniului de vizibilitate.
Aceste operaţii de alocare - dezalocare de memorie sunt gestionate de compilator şi
ele sunt foarte eficiente. Programul însă, trebuie să cunoască la momentul compilării,
numărul şi mărimea acestor variabile.
3. zona de memorie poate fi alocată în zona de heap. Alocarea de memorie în zona de
heap se realizează la execuţia programului şi se numeşte alocare dinamică. Alocarea
dinamică se realizează prin intermediul unor funcţii cărora li se transmite necesarul de
memorie de alocat. Programatorul este cel care controlează mărimea memoriei astfel
alocate precum şi momentul alocării. Dezalocarea este, de asemenea, lăsată la decizia
programatorului. Domeniul de viaţă a acestor variabile dinamice este determinat de
programator şi nu intră sub incidenţa unor reguli de limbaj.
2.6.2. Crearea dinamică a obiectelor în C şi C++
C furnizează 2 funcţii de bază pentru alocare şi dezalocare de memorie: malloc şi free.
Aceste funcţii sunt practice dar destul de primitive, în sensul că programatorul trebuie să
cunoască bine mecanismul de alocare / dezalocare a memoriei dinamice pentru a le putea
folosi. La apel funcţia malloc trebuie să primească la intrare mărimea zonei de memorie
care se doreşte alocată şi returnează un pointer de tip void*. Deci malloc nu returnează o
zonă de memorie aferentă unui tip anume, iar în acest caz programatorul trebuie să
convertească acest pointer spre tipul dorit. După alocarea memoriei cu malloc
programatorul trebuie să se ocupe în mod explicit de iniţializarea zonei de memorie. C nu
asigură o iniţializare automată. La dezalocare, apelul funcţiei free trebuie precedat de
apelarea unor destructori care să efectueze operaţii uzuale de ştergere a obiectului.
Efortul de programare solicitat de limbajul C pentru utilizarea alocării dinamice a
memoriei este destul de mare, iar mecanismele de gestiune a memoriei sunt greoaie. De
acea mulţi programatori evită pe cât posibil să folosească alocarea dinamică a memoriei,
utilizând mecanisme alternative mult mai ineficiente.
C++ a combinat acţiunile necesare creării dinamice a unui obiect în apelul unui singur
operator: new. Acesta asigură alocarea memoriei necesare obiectului în zona de heap şi
iniţializarea obiectului nou creat prin apelarea unui constructor. new poate fi utilizat pentru
crearea obiectelor folosind oricare din constructorii definiţi pentru o clasa anume.
Operatorul delete este complementar operatorului new. Acest operator asigură mai întâi
apelarea destructorului şi apoi eliberarea memoriei. delete poate fi apelat doar pentru
eliberarea memoriei alocate prin new. Nu se poate apela delete pentru dezalocarea
memoriei alocate prin malloc. La ştergerea prin delete a unui pointer care indică valoarea
0 (NULL) practic, nu se întâmplă nimic, de acea se obişnuieşte setarea unui pointer cu
valoarea NULL imediat după ce a fost dezalocat cu delete.
2.6.3. Managementul memoriei la execuţie
La crearea obiectelor la momentul compilării, mărimea şi durata de viaţă a obiectelor este
strict determinată. Astfel, compilatorul “ştie” în ce moment şi unde (locaţia exactă de pe
stivă) să aloce aceste obiecte. El va gestiona stiva într-un asemenea mod astfel încât
programul să poată rula cu succes. Această gestiune a memoriei (a stivei) este realizată în
momentul compilării / link-editării, atunci când se stabilesc locaţiile şi momentele unde
respective când se vor aloca obiectele.
În cazul alocării dinamice a memoriei, există o încărcare suplimentară a programului
pentru a determina locaţia unde să se aloce memorie pentru o variabilă dinamică. La o
cerere de alocare, (malloc), funcţia care implementează această alocare de memorie
trebuie să caute un bloc de memorie liberă suficient de mare, pentru a satisface nevoia de
alocare şi, înainte să returneze pointerul către zona de memorie alocată, trebuie să
marcheze această zonă ca fiind ocupată într-o hartă a memoriei. Toate aceste proceduri
necesare a fi executate la alocarea dinamică sunt implementate în cadrul unei strategii
numite “managementul memoriei”, şi reprezintă o încărcare suplimentară pentru sistem.
Diverse compilatoare de C/C++ realizează managementul memoriei în mod diferit, însă
rezultatul acestor funcţii trebuie să fie acelaşi: să corespundă standardului C/C++.
Multe limbaje avansate de programare tratează în mod diferit problema gestiunii
dinamice a memoriei. Astfel, se pune la dispoziţia programatorului mecanismul denumit
garbage collector care adună şi dezalocă toată memoria alocată dinamic dar rămasă
agăţată, fără posibilitatea de a mai fi referită. Mediile de programare Java şi .Net
furnizează asemenea mecaniste. În prezenţa garbage collector-ului, programatorul nu mai
trebuie să se preocupe de dezalocarea memoriei alocate dinamic, deoarece sistemul va
asigura în mod automat acest lucru.
La apelarea operatorului new se alocă memorie pentru obiect şi apoi se apelează
constructorul. La apelul operatorului delete se apelează destructorul şi apoi se dezalocă
memoria. Prin suprascrierea operatorilor new şi delete se poate schimba modul de alocare
respectiv dezalocare a memoriei, însă mecanismele de apel ale constructorului şi
destructorului rămâne neschimbate.
Mecanismul de alocare respectiv dezalocare a memoriei oferite de new şi delete este unul
de utilizare generală. În majoritatea cazurilor acesta satisface nevoile programelor
noastre. Unul din motivele care justifică rescrierea acestor operatori este cel de eficienţă.
Supraîncărcarea operatorilor new şi delete permite implementarea unui mecanism de
alocare respectiv dezalocare particular, propriu cerinţelor problemelor concrete.
În cele ce urmează dăm exemple de probleme concrete care necesită supraîncărcarea
acestor operatori:
- existenţa unei clase la care frecvenţa creării obiectelor să fie foarte mare. În acest caz,
apar probleme de rapiditate a apelurilor operatorului new
- existenţa unei frecvenţe mari a alocării memoriei pentru obiecte de dimensiuni
diferite. În acest caz, se poate ajunge la o fragmentare mare a memoriei, care să nu
mai permită alocări de obiecte mari, chiar dacă memorie liberă (necontiguă) există.
- probleme la alocare apar de obicei în sisteme în timp real care lucrează cu resurse
limitate pentru perioade lungi de timp. În astfel de sisteme alocarea memoriei trebuie
să dureze un timp foarte scurt şi de obicei, constant, iar probleme de epuizare a
memoriei trebuie excluse.
La supraînărcarea operatorului new când un obiect este creat prin intermediul noului
operator, compilatorul asigură următoarea funcţionalitate:
- se alocă spaţiul de memorie folosind noul operator new
- se apelează constructorul
La supraîncărcarea operatorului delete, când un obiect este distrus prin intermediul noului
operator, compilatorul asigură următoarea funcţionalitate:
- se apelează destructorul
- se dezalocă spaţiul de memorie folosind noul operator delete.
Trebuie ţinut cont că la rescrierea acestor operatori trebuie furnizat (rescris) şi
mecanismul de management al memoriei în cazul în care aceasta se epuizează.
2.7. Programarea cu obiecte din mai multe clase
2.7.1. Compoziţia
Compoziţie înseamnă a crea o noua clasă care conţine ca şi date membre obiecte din clase
existente. Se spune că un obiect din noua clasă agregă obiecte din clasele vechi
(existente).
Compoziţia (agregarea) este de două tipuri:
- directă: atunci când obiectul membru este în mod efectiv parte componentă a
obiectului din noua clasa
- indirectă: atunci când obiectul din noua clasă referă (prin intermediul unui pointer) un
obiect din clasa existentă
În descriere UML, structura de clase de mai sus poate fi reprezentată prin figura 79:
+X()+set(in ii : int) : void+read() : int+permute() : int
-i : int
X
+Y()+f(in ii : int) : void+g() : int+permute() : void
-i : int-x : X
Y
11
Figura 19.2 Reprezentarea grafică a agregării
Rombul plin înseamnă agregare directă. Agregarea este expresia unei relaţii de tipul
“has-a”, şi se traduce prin expresia: un obiect din tipul X este membru al unui obiect din
tipul Y, sau un obiect din tipul Y se compune dintr-un obiect din tipul X.
2.7.2. Moştenirea
Prin moştenire se creează o clasă nouă (clasa derivată) care se aseamănă cu o clasă
existentă (clasa de bază). Moştenirea este expresia unei relaţii de tipul “is-like-a”.
Sintaxa moştenirii presupune înşiruirea claselor de bază după numele clasei care se
creează, înainte de a se trece la corpul definiţiei noii clase. C++ permite moştenirea
multiplă, adică derivarea unei clase utilizând mai multe clase de bază.
Prin moştenire noua clasă va avea ca şi membrii toţi membrii clasei de bază. În plus, noua
clasă poate să îşi definească proprii membrii, sau să rescrie definiţiile membrilor din clasa
de bază. Subsetul de membrii din clasa de bază care vor fi moşteniţi în clasa derivată se
determină utilizând regulile de combinare a specificatorilor de acces.
În rescriere UML, structura de clase din exemplul de mai sus este reprezentată în figura
19.3.
+X()+set(in ii : int) : void+read() : int+permute() : int
-i : int
X
+Y()+change() : int+set(in ii : int) : void
-i : int
Y
Figura 19.3 Reprezentarea grafică a moştenirii
Noua clasă Y moşteneşte toate datele membre ale lui X. De fapt Y conţine un subobiect
de tipul X ca şi cum am fi realizat agregarea tipul X în tipul Y. Astfel, la apelul sizeof se
remarcă faptul că Y ocupă mai mult spaţiu decât X. Membrii privaţi ai lui X există în
continuare ca şi membrii privaţi ai subobiectului, însă mecanismul private funcţionează,
adică nu putem accesa aceşti membrii din afara obiectului de care aparţin (care este de tip
X). Deci, Y moşteneşte doar membrii din interfaţa lui X, la care se poate avea acces din
afară.
Se observă modul în care metoda set din clasa Y redefineşte metoda set din clasa X. In
acest caz, nu e vorba de supraîncărcare, deoarece semnăturile celor 2 funcţii (cea din
clasa X şi cea din clasa Y) sunt identice. La invocarea metodei set pe un obiect din clasa
Y se va apela metoda set redefinită.
Se observă utilizarea cuvântului cheie public înainte de numele clasei de bază. Dacă s-ar
omite această specificare, în mod implicit, moştenirea funcţionează ca şi privată, adică,
toţi membrii publici ai clasei de bază s-ar moşteni ca şi membrii privaţi în clasa derivată.
Prin folosirea cuvântului cheie public, toţi membrii publici din clasa de bază se
moştenesc ca şi membrii publici în clasa derivată.
2.7.3. Combinarea compoziţiei cu moştenirea
Compoziţia şi moştenirea pot fi combinate la libera alegere a programatorului. Astfel, se
pot crea sisteme oricât de complexe şi extensibile. Figura de mai jos prezintă un exemplu
de combinare a agregării cu moştenirea.
+A()+~A()+f() : void
-i : int
A
+B()+~B()+f() : void
-i : int
B
+C()+~C()+f() : void
-a : A
C11
Figura 19.4 Agregare şi moştenire combinate
Se observă că de multe ori, la crearea obiectelor complexe este nevoie de apel explicit al
constructorilor.
Apelul destructorilor acestor obiecte se realizează în mod automat, de către compilator.
Secţiunea următoare va trata în detaliu ordinea de apel automat a constructorilor şi
destructorilor, în cazul combinării compoziţiei cu moştenirea.
2.7.4. Ini ţializarea obiectelor în cazul folosirii compoziţiei şi moştenirii
La crearea unui obiect, compilatorul C++ garantează apelarea constructorilor pentru toate
obiectele membre ale obiectului respectiv. Aceasta înseamnă apelarea constructorului
implicit pentru sub-obiecte.
Problema apare în momentul în care clasele agregate nu au definiţi constructori implicit,
sau atunci când la iniţializare dorim să transmitem un alt argument în locul argumentului
implicit. Această problemă este rezolvată prin utilizarea listei de iniţializare.
În cazul moştenirii, utilizarea listei de iniţializare presupune apelarea explicită a
constructorului clasei de bază.
Constructorii obiectelor agregate trebuie să fie apelaţi înainte de a se trece la execuţia
codului din corpul constructorului clasei curente. În momentul în care se execută corpul
constructorului curent, obiectele membre trebuie să fie deja iniţializate.
Regula de apelare a constructorilor şi destructorilor în cazul utilizării combinate a
agregării şi moştenirii este prezentată în cele ce urmează:
Mai întâi se apelează constructorul clasei de bază, apoi se apelează constructorii
obiectelor agregate, în ordinea in care apar in definiţia clasei.
Ordinea constructorilor în lista de iniţializare nu afectează ordinea în care sunt apelaţi
constructorii. Pentru fiecare apel de constructor, această regulă se aplică întocmai.
Destructorii se apelează întotdeauna în ordine inversă a apelului constructorilor, pentru
aceleaşi obiecte.
2.7.5. Ascunderea numelor prin moştenire
În general, se poate spune că ori de câte ori într-o clasă derivată se redefineşte o funcţie
supraîncărcată în clasa de bază, funcţiile supraîncărcate din clasa de bază devin ascunse.
Se consideră că prin moştenire se importă interfaţa clasei de bază în clasa derivată.
Astfel, dacă clasa de bază suportă polimorfismul prin supraîncărcarea funcţiilor, atunci
această facilitate rămâne prin utilizarea moştenirii. Dacă, în plus, în clasa derivată, se
schimbă definiţia uneia din funcţiile importate care intră sub incidenţa polimorfismului,
înseamnă că se doreşte schimbarea interfeţei moştenite din clasa de bază, deci aceasta nu
se mai moşteneşte deloc in clasa derivată.
Redefinirea funcţiilor membre ale claselor de bază este utilă la specializarea colecţiilor
generice. De multe ori este nesigur să returnăm pointeri void, deoarece aceştia pot fi
convertiţi de către programul client către orice tip dorit, fără să fim atenţionaţi în vreun
fel de compilator.
Soluţia este să specializăm clasa colecţie generică pentru un anume tip de elemente.
Astfel, vom moşteni din clasa generică o nouă clasă care va avea redefinite funcţiile din
interfaţă pentru a suporta un anume tip specific.
Problema acestei abordări este că prin specializare schimbăm în mod drastic interfaţa
clasei de bază. Se pune problema utilităţii moştenirii în acest caz.
La realizarea operaţiei de moştenire, următoarele funcţii membre nu se moştenesc din
clasa se bază în clasa derivată:
- constructorii şi destructorii
- operatorul de atribuire, deoarece realizează o operaţie similară constructorului de
copiere
2.7.6. Considerente de design a claselor
În această secţiune se vor prezenta câteva considerente care trebuiesc urmate la alegerea
între utilizarea compoziţiei şi a moştenirii la crearea aplicaţiilor orientate obiect.
Compoziţia şi moştenirea reprezintă două modalităţi prin care programele existente se pot
extinde şi reutiliza. Avantajul principal al acestor două modalităţi de reutilizare a codului
este posibilitatea de dezvoltare incrementală a programelor. Astfel, putem scrie cod nou,
respective să adăugăm facilităţi noi programelor existente fără să fie nevoie să rescriem
codul deja existent. Astfel evităm introducerea de buguri în programele existente care
probabil sunt deja funcţionale. Bugurile care apar în programe pot fi astfel izolate la
nivelul noului cod scris.
Este important de înţeles că dezvoltarea incrementală a programelor este similară cu
procesul uman de învăţare. Astfel, referitor la un domeniu anume, putem realiza o
activitate de analiză oricât de cuprinzătoare dar nu vom reuşi să cuprindem toate
aspectele domeniului la momentul realizării analizei. Astfel, pentru a putea continua şi a
reuşi să furnizăm software util, dezvoltarea incrementală devine esenţială.
2.7.7. Subtyping
În general, compoziţia se utilizează atunci când dorim să utilizăm caracteristicile unui
obiect dintr-o clasă existentă, dar nu dorim să furnizăm aceeaşi interfaţă. Astfel, vom
agrega obiectul din clasa existentă ca şi un membru privat al noii clase.
O a doua modalitate de utilizare a agregării este atunci când dorim să extindem un tip
existent cu o nouă funcţionalitate. Dacă pentru realizarea acestei noi funcţionalităţi avem
nevoie de date noi, o soluţie ar fi agregarea unui obiect din tipul existent împreună cu
datele noi şi crearea unui tip nou. Tipul nou trebuie să fie echipat cu un mecanism de
conversie automată înspre tipul care se doreşte extins, pentru a putea utiliza noile obiecte
cu funcţiile client existente deja pentru tipul vechi.
Această abordare a problemei este depăşită, datorită faptului că ea solicită rescrierea
tuturor metodelor interfeţei tipului vechi în noul tip. Chiar dacă am echipat noua clasă cu
un mecanism de conversie automată, aceasta nu se realizează în cazul în care obiectul din
tipul respectiv este prezent într-o expresie ca şi operand stânga. În acest caz, soluţia este
utilizarea moştenirii.
De exemplu, putem extinde clasa ifstream, adăugând o nouă funcţionalitate: memorarea
numelui fişierului asociat obiectului de tip ifstream. Pentru aceasta, noua clasă trebuie să
moştenească din clasa ifstream şi să agrege un obiect de tipul string în care să se
memoreze numele fişierului asociat. Acest procedeu se numeşte sub-typing.
2.8. Upcast
Upcast reprezintă una din proprietăţile cele mai importante introduse de paradigma
obiectuală prin conceptual de moştenire. Astfel, upcast înseamnă că obiectele din tipul
claselor derivate sunt în acelasi timp şi din tipul clasei de bază.
Să considerăm o clasă de bază Intrument, şi o clasă derivată Wind. Clasa derivată
moşteneşte toate metodele interfeţei clasei de bază. Astfel, dacă vom putea trimite un
mesaj unui obiect de tip Instrument, acelaşi mesaj îl vom putem trimite şi unui obiect
de tipul Wind. Astfel, putem spune că un obiect de tip Wind este în acelaşi timp şi de
tipul Instrument. Clasa Instrument deţine o metodă play care este specifică claselor
derivate, precum clasa Wind.
Oricum, la transmiterea argumentelor pentru o funcţia redefinită a clasei Instrument,
compilatorul realizează conversia automată a pointerului de tip Wind în pointer de tip
Instrument. Aceasta reprezintă în fapt, operaţia de upcast.
Revenind la întrebarea referitoare la alegerea între compoziţie şi moştenire, una din
modalităţile cele mai bune de a decide care concept să-l folosim este să încercăm să
răspundem la următoarea întrebare: pe parcursul programului, operaţia de upcast va fi
necesară? În caz afirmativ, se recomandă utilizarea moştenirii. În caz negativ, utilizarea
compoziţiei.
2.8.1. Extensibilitate prin func ţii virtuale
La apelul unei funcţii, aducerea în contextul curent şi execuţia corpului unei funcţii se
numeşte legarea funcţiilor . Atunci când legarea funcţiilor se face înainte de execuţia
programului, se spune că avem legare “înainte” (early binding). Early binding se face
întotdeauna în faza de link-editare a programului. În limbajele procedurale (C) early
binding reprezintă singura modalitate de legare a corpului unei funcţii de apelul acesteia.
Alternativa la early binding o reprezintă “legarea întârziată” ( late binding) adică legarea
dinamică a corpului funcţiei de apelul acesteia exact înainte de realizarea apelului.
Legarea întârziată presupune existenţa unui mecanism care să determine tipul exact al
obiectului înainte de apelul funcţiei. Astfel, prin determinarea exactă a tipului înainte de
apel, se poate face legare la versiunea corectă a funcţiei, şi anume la cea re-definită în
cazul în care ne situăm într-un program cu ierarhii de clase.
Pentru a determina activarea mecanismului de legare întârziată pentru o funcţie declaraţia
acestei funcţii în clasa de bază trebuie precedată de cuvântul cheie virtual. Redefinirea
unei funcţii virtuale într-o clasă derivată se numeşte “overriding”. Prin declararea virtuală
a unei funcţii într-o clasă de bază, pentru oricare din funcţiile redefinite în clasele
derivate se va realiza legarea întârziată.
In exemplul analizat, aceasta presupune inserarea cuvântului cheie virtual înainte de
declaraţia metodei play în clasa Instrument.
Mecanismul funcţiilor virtuale funcţionează indiferent de nivelul de moştenire.
În contextul polimorfismului, metodele care se apelează pentru obiecte din clasele de
bază (precum metoda tune) pot fi privite ca şi o trimitere de mesaj către un obiect şi
responsabilizarea obiectului destinaţie cu privire la modul de tratare a mesajului. Astfel,
obiectul (în funcţie de tipul său determinat la execuţie) va trata mesajul într-o manieră
proprie, specifică.
2.8.2. Mecanismul de implementare a funcţiilor virtuale
Mecanismul funcţiilor virtuale se bazează pe o tabelă numită tabela funcţiilor virtuale.
Această tabelă există pentru fiecare clasă care conţine funcţii virtuale şi este gestionată
de compilator. Astfel, atunci când compilatorul întâlneşte la compilarea unei clase
declaraţia unei funcţii virtuale, plasează în această tabelă un pointer către această funcţie.
Astfel, tabela funcţiilor virtuale va conţine referinţe către toate funcţiile virtuale, pentru o
anume clasă. În fiecare clasă care conţine funcţii virtuale, compilatorul va crea un
membru (ascuns) care este o referinţă către tabela funcţiilor virtuale. La apelul unei
funcţii virtuale printr-o referinţă de tip upcast, compilatorul generează cod (în codul
obiect rezultat după compilare) care se va executa exact înainte de realizarea apelului.
Acest cod accesează tabela funcţiilor virtuale prin pointerul membru ascuns şi datorită
faptului că la execuţie se cunoaşte tipul obiectului sub care se face apelul, se regăseşte din
tabela funcţiilor virtuale adresa corectă a funcţiei care trebuie să se apeleze şi astfel se
asigură apelul funcţiei corespunzătoare.
Mecanismul descris mai sus este realizat în mod automat de compilator. Singura
problemă rămasă neclarificată este modul în care se realizează determinarea tipului la
execuţie. Aceasta este necesară pentru lucrul cu tabela funcţiilor virtuale.
Modul de memorare a tipului în cadrul obiectelor se poate determina prin crearea de
obiecte fără date membre. Astfel, chiar dacă avem un tip care nu are date membre, la
crearea de obiecte din tipul respectiv vom vedea că obiectele respective ocupă memorie.
Deci sizeof aplicat pe acele obiecte returnează o valoare diferită de 0. Zona respectivă
de memorie este ocupată de un membru “dummy” care există in orice tip şi deci în orice
obiect. Acel membru dummy este utilizat tocmai pentru a stoca tipul obiectului.
Prin interogarea, la execuţie, a membrului dummy se poate afla tipul concret al obiectului
(la execuţie).
Operaţia de upcast se realizează doar în cazul convertirii între tipuri-adrese. Astfel, având
adrese la dispoziţie, şi nu obiecte efective, compilatorul poate amâna realizarea legării
unui apel de funcţie cu corpul funcţiei.
Regula de bază după care funcţionează compilatorul este că ori de câte ori are
posibilitatea, acesta realizează legare “înainte”. Astfel, în cazul obiectelor efective
(variabilelor de tip obiect), întotdeauna se utilizează early binding, iar pentru utilizarea
mecanismului funcţiilor virtuale e nevoie de folosirea pointerilor.
Mecanismul funcţiilor virtuale asigură apelul funcţiei potrivite la contextul potrivit. Se
pune întrebarea de ce acest mecanism nu este apelat întotdeauna, pentru orice apel de
funcţie?
Problema este una de eficienţă. Astfel, un apel de funcţie virtuală este destul de costisitor
- ca şi timp de execuţie deoarece presupune inserare de cod suplimentar la momentul
compilării, şi
- ca şi spaţiu de memorie ocupată deoarece e nevoie de o tabelă suplimentară, tabela
funcţiilor virtuale.
Datorită faptului ca C++ provine din C care este un limbaj orientat spre eficienţă,
alegerea a fost să se ofere mecanismul funcţiilor virtuale doar la cerere. Deci, implicit o
funcţie în C++ este non-virtuală. Se recomandă utilizarea funcţiilor virtuale doar în cazul
creării de clase polimorfice, deci a utilizării conceptului de moştenire.
2.9. Clase abstracte
De multe ori în designul unei aplicaţii dorim să punem în clasa de bază doar interfaţa
viitoarelor obiecte, şi nu să creăm obiecte din clasa respectivă. Clasa de bază va fi
folosită doar pentru operaţia de upcast care va permite astfel tratarea unitară a obiectelor
derivate, care au aceeaşi interfaţă. Pentru a realiza acest lucru, vom transforma clasa de
bază într-o clasă abstractă.
O clasă se numeşte abstractă dacă are cel puţin o funcţie virtuală pură. O funcţie virtuală
pură este întotdeauna precedată de cuvântul cheie virtual şi declaraţia e urmată de =0.
Compilatorul împiedică definirea de obiecte din clasele abstracte.
Atunci când se moşteneşte dintr-o clasă abstractă, clasa derivată trebuie să implementeze
toate funcţiile virtuale pure. În cazul în care în clasa derivată nu este implementată cel
puţin o funcţie virtuală pură, clasa derivată devine la rândul său o clasă abstractă.
Funcţiile virtuale pure forţează astfel să fie implementate, în clasele derivate, care se
doresc a fi clase efective.
În exemplul cu ierarhia de instrumente muzicale, metodele din interfaţa acestei clase sunt
doar de complezenţă, adică au fost definite deoarece este nevoie de definirea lor pentru ca
programul să funcţioneze. Aceste metode nu furnizează un răspuns util. Intenţia clasei
Instrument este să se creeze o interfaţă pentru clasele care se vor deriva, astfel încât
obiectele din clasele derivate să poată fi folosite in mod unitar. Astfel, clasa Instrument
este un bun exemplu de posibilă clasă abstractă. De obicei, se creează o clasă abstractă
atunci când dorim să manipulăm obiecte din diverse clase printr-o interfaţă comună.
Clasele abstracte de obicei nu implementează interfaţa respectivă.
În alte limbaje obiectuale, există conceptual distinct de interfaţă. Astfel, în Java o
interfaţă este similară unei clase abstracte, cu deosebirea că interfeţele nu agregă obiecte,
şi toate metodele din interfaţă sunt metode virtuale pure.
Prin utilizarea funcţiilor virtuale pure şi a claselor abstracte se împiedică crearea efectivă
de obiecte din aceste clase. Astfel, tipul unei clase abstracte nu poate fi folosit ca şi tip de
argument transmis prin valoare, în cazul unei funcţii oarecare. Se forţează astfel utilizarea
pointerilor, deci asocierea dintre upcast şi pointeri.
Cu toate că de obicei funcţiile virtuale pure nu sunt implementate în clasa abstractă, este
totuşi posibil să se scrie implementarea acestor funcţii. Chiar dacă o clasă abstractă va
implementa o funcţie virtuală pură (adică funcţia este declarată în definiţia clasei ca şi
virtuală pură, dar programatorul scrie o definiţie pentru această funcţie), mecanismul
clasei abstracte se păstrează, adică, în continuare, compilatorul împiedică crearea de
obiecte din această clasă.
De multe ori definirea funcţiilor virtuale pure este convenabilă atunci când dorim să
scriem o bucată de cod care să fie apoi apelată din toate sau din unele clase derivate,
eventual din metodele care redefinesc această funcţie.
2.9.1. Implementarea claselor în contextul funcţiilor virtuale
În cazul moştenirii şi a redefinirii, se pune problema modului de funcţionare a funcţiilor
virtuale. Să presupunem cazul în care în clasa de bază există funcţii virtuale. În acest caz,
se creează o tabelă a funcţiilor virtuale pentru clasa de bază, tabelă care conţine locaţii
pentru toate funcţiile virtuale declarate în clasă. În cazul în care se creează o nouă clasă
derivată din clasa de bază, tabela funcţiilor virtuale pentru noua clasă va conţine intrări
pentru funcţiile virtuale din clasa de bază exact la aceeaşi indici ca şi tabela funcţiilor
virtuale a clasei de bază. Dacă redefinim o funcţie virtuală a clasei de bază, tabela
funcţiilor virtuale pentru clasa derivată va memora adresa funcţiei redefinite în locul
adresei funcţiei din clasa de bază. Dacă clasa derivată declară are în plus alte funcţii
virtuale, acestea se vor adăuga în locaţii succesive în tabela funcţiilor virtuale din clasa
derivată. Astfel, modul de scriere (declarare) a funcţiilor virtuale nu este identic cu modul
de stocare a acestora în tabela funcţiilor virtuale.
Astfel, la realizarea operaţiei de upcast, atunci când compilatorul vede o referinţă la un
obiect din clasa de bază, el de fapt vede (şi accesează) zona de tabelă virtuală
corespunzătoare obiectului din clasa derivată (la momentul apelului). La utilizarea unui
pointer dintr-un tip de bază, chiar dacă acesta referă un obiect din clasa derivată,
compilatorul nu va şti să acceseze decât funcţii din propria tabela virtuală. Pentru a se
accesa şi alte funcţii, e nevoie de realizarea explicită a operaţiei de “downcast” (conversie
explicită înspre tipul clasei derivate). La realizarea operaţiei de downcast, programatorul
trebuie să ştie ca întradevăr, la momentul apelului, pointerul respectiv referă un obiect din
tipul derivat. Compilatorul nu are de unde să cunoască tipul obiectului la momentul
realizării apelului.
Transmiterea prin referinţă permite folosirea upcast-ului. Acesta este posibil datorită
faptului că, indiferent de tipul actual referit, pointerii au aceeaşi mărime fizică. În cazul
obiectelor, dacă ele sunt din tipuri diferite este de aşteptat ca ele să ocupe spaţii de
memorie de mărime diferită. Mai mult, în cazul derivării, un obiect din clasa derivată
ocupă cel puţin atâta memorie cât obiectul din clasa de bază. Astfel, dacă transmitem prin
valoare un obiect din clasa derivată într-un obiect din clasa de bază se realizează o
transferare efectivă doar a sub-obiectului din clasa de bază, conţinut în obiectul din clasa
derivată. Deci, tot ce s-a redefinit sau adăugat prin derivare se pierde. Compilatorul nu
va genera eroare deoarece tipurile sunt compatibile.
Suprascrierea unei funcţii definite în clasa de bază ascunde toate definiţiile şi formele
funcţiei respective din clasa de bază. Această regulă se menţine şi în cazul în care se
utilizează funcţiile virtuale, cu o singură excepţie: nu vom putea redefini o funcţie
virtuală prin schimbarea tipului de return. Mai mult, dacă redefinim o funcţie virtuală şi
schimbăm semnătura funcţiei (numărul şi tipul argumentelor) atunci în clasa derivată se
va vadea numai noua funcţie. În schimb, când realizăm operaţia de upcast se vor vedea
numai funcţiile din clasa de bază.
Astfel prin redefinire putem schimba tipul de return a funcţiei din clasa de bază, dacă
funcţia respectivă nu este virtuală. Această regulă e îngrădită puţin în cazul în care
funcţia din clasa de bază returnează un pointer către un tip care la rândul său poate fi
vârful unei ierarhii de clase. Astfel, în clasa derivată, dacă dorim să schimbăm tipul de
return, putem cel mult să îl schimbăm într-un pointer către o clasă derivată din tipul de
return a clasei de bază.
În cazul claselor care conţin funcţii virtuale, constructorii acestor clase realizează
iniţializarea tabelei funcţiilor virtuale. Această operaţie e realizată în mod automat de
constructor. Astfel, compilatorul plasează cod la intrarea în orice constructor care să
iniţializeze tabela funcţiilor virtuale.
La apelul constructorilor, ordinea de iniţializare se păstrează. Astfel, la construirea unui
obiect dintr-o clasă derivată, mai întâi se apelează constructorul clasei de bază şi apoi se
trece la execuţia constructorului curent. Apelul constructorului clasei de bază se
realizează la intrarea în constructorul clasei curente.
În cazul în care din constructor se apelează o funcţie virtuală, se va face legare doar către
versiunea locală (din clasa) a acelei funcţii. În cadrul constructorului mecanismul
funcţiilor virtuale e dezactivat. O funcţionalitate similară se întâmplă şi în cazul
destructorilor. Astfel, dacă într-un destructor se apelează o funcţie virtuală, întotdeauna
de va apela versiunea locală a acestei funcţii.
În cazul constructorilor nu se poate folosi cuvântul cheie virtual.
În cazul destructorilor se poate folosi cuvântul cheie virtual şi astfel se creează destructori
virtuali.
Apelul destructorilor se realizează în ordine inversă faţă cel al constructorilor. Astfel,
destructorii se apelează pornind de la clasa cea mai din josul ierarhiei şi continuă până la
apelul destructorului clasei din vârful ierarhiei.
Folosirea destructorilor virtuali intervine atunci când dorim să eliberăm memoria referită
de pointeri generici, care indică către obiecte din tipul de bază. Astfel de pointeri, pot
referi (prin upcast) obiecte derivate. Dacă apelăm operatorul delete pentru un asemenea
pointer, el va şti să apeleze destructorul clasei de bază, care nu are cum să elibereze
corect memoria pentru un obiect din clasa derivată. In acest caz, e nevoie ca obiectele din
clasa derivată să conţină destructorul în lista funcţiilor virtuale, pentru a se putea apela
destructorul corect.
2.9.2. Downcast
Downcast reprezintă operaţia de conversie între un tip din partea de sus a unei ierarhii şi
un tip specializat. Downcast este operaţia inversă upcastului. La upcast realizarea
conversiei este o chestiune simplă, datorită faptului că se cunoaşte şi se determină exact
tipul destinaţie a conversiei. La downcast, problema apare datorită faptului că tipul
destinaţie nu este unic determinat.
2.10. Design pattern-i
Prin pattern ne referim la o problemă (de programare) care se repetă foarte frecvent în
proiectele pe care trebuie să le realizăm. Datorită acestei frecvenţe a repetării, putem să
presupunem că la fiecare întâlnire a problemei respective vom folosi acelaşi procedeu
rezolvare. Deci, prin pattern putem înţelege atât problema în sine cât şi soluţia adoptată
generic pentru rezolvarea acestei probleme. Astfel, un design pattern reprezintă o soluţie
conceptuală în termeni de programare obiectuală furnizată unui tip probleme specifice.
Design pattern-urile reprezintă un nivel superior de înţelegere a programării obiectuale şi
ajută programatorii să realizeze soluţii scalabile, flexibile la modificarea cerinţelor, re-
utilizabile în condiţiile unor probleme similare.
2.10.1. Singleton
Singleton reprezintă unul din cele mai folosite şabloane de proiectare (design pattern).
Singleton-ul se utilizează atunci când dorim să ne asigurăm că doar un singur obiect
dintr-o clasă va putea exista la un moment dat în aplicaţie. Un singleton se defineşte prin
agregarea ca şi dată membru statică a unei clase a unui obiect de tipul clasei respective.
Prin declararea constructorului clasei respective ca şi privat se poate obţine existenţa unui
singur obiect din clasa respectivă, pe toată execuţia programului. Accesarea acestui
obiect se realizează prin intermediul unei funcţii statice. Astfel, singleton-ul asigură un
punct de acces global la obiectul respectiv.
Figura 19.5 prezintă diagramă UML asociată pattern-ului Singleton. Figura 45 prezintă
implementarea pattern-ului în C++.
+static getInstance() : *Singleton-Singleton()
-static instance : Singleton
Singleton
1
1
Figura 19.5 Design pattern-ul Singleton în specificaţie UML
Declararea privată a unui constructor de copiere este necesară pentru a înlătura alte
modalităţi de creare a obiectelor de tip Singleton. Constructorii de copiere vor fi trataţi în
capitolul următor.
2.10.2. Iterator
Un iterator este un obiect asociat unei colecţii de obiecte. Prin intermediul iteratorului
putem avea acces la câte un membru al colecţiei la un moment dat. Prin intermediul
iteratorului se inhibă accesul direct la colecţia asociată acestuia. Supraîncărcarea
operatorului de dereferenţiere se realizează printr-o funcţie membră. Aceasta trebuie să
returneze un obiect (sau o referinţă către un obiect) care poate fi operand în operaţia de
dereferenţiere (sau poate returna un pointer care să permită operaţia de selecţie).
+f() : void+g() : void
-static i : int-static j : int
Obj
+add(in obj : Obj*) : void
-a : vector<Obj*>
ObjContainer
+SmartPointer(in objc : ObjContainer&)+operator++() : bool+operator++(in dummy : int) : bool+operator ->() : Obj*
-oc : ObjContainer&
SmartPointer
Figura 19.6 Diagrama UML asociată unui iterator implementat prin suprascrierea operatorilor
2.11. Întreb ări recapitulative
1. Care sunt principalele caracteristici ale unui limbaj de programare obiectual?
2. Descrieţi actorii care participa la scrierea şi utilizarea programelor informatice. Care
este scopul acestor actori?
3. Care sunt paşii care trebuie urmaţi pentru realizarea programelor, conform
metodologiei obiectuale?
4. Descrieţi metoda pentru eXtreme Programming, pentru realizarea programelor
informatice.
5. Ce este un obiect obiect? În ce constă implementarea şi interfaţa obiectului?
6. Ce înţelegeţi prin „separarea interfeţei de implementare” şi cum se realizează acest
lucru în C++?
7. De ce este necesară ascunderea implementării?
8. Care sunt specificatorii de acces folosiţi în C++?
9. Ce este un constructor? Care sunt tipurile de constructori posibili ai unei clase?
10. Ce este un destructor?
11. Ce este un constructor de copiere? De ce este acesta important?
12. Cum se poate evita folosirea constructorului de copiere în programele obiectuale?
13. Ce înţelegeţi prin „supraîncărcarea funcţiilor”?
14. Ce înţelegeţi prin „supraîncărcarea operatorilor”?
15. Enunţaţi câteva reguli de care trebuie să se ţină cont la supraîncărcarea operatorilor.
16. Care sunt principiile care trebuie aplicate la supraîncărcarea operatorului de atribuire?
17. Care este criteriul esenţial care trebuie aplicat la alegerea între supraîncărcarea unui
operator prin funcţie membră sau funcţie globală?
18. Descrieţi metoda numită „numărarea referinţelor” care se poate aplica la
supraîncărcarea operatorului de atribuire.
19. Care sunt acţiunile care se efectuează la crearea efectivă unui obiect pe parcursul
rulării programului?
20. Cum se realizează alocarea memoriei în C++?
21. Cum se realizează crearea dinamică a obiectelor în C++?
22. Descrieţi alternativele existente de gestiune a memoriei în C++?
23. Cum realizează limbajul de programare JAVA gestiunea memoriei?
24. Definiţi compoziţia. De câte tipuri este aceasta?
25. Definiţi moştenirea şi enuntaţi principalele caracteristici ale acesteia.
26. Cum se realizează iniţializarea obiectelor în cazul combinării compoziţiei cu
moştenirea?
27. Cum se realizează ascunderea numelor prin moştenire? Ce este specialiarea?
28. Ce înseamnă subtyping? Cum se poate realiza acest procedeu prin compoziţie
respectiv prin moştenire?
29. Ce înseamnă upcast?
30. Definiţi conceptul de legare a funcţiilor şi cum se realizează acest concept?
31. Ce este o funcţie virtuală? Care sunt avantajele folosirii acestor funcţii?
32. Care este mecanismul de implementare a funcţiilor virtuale?
33. Ce înseamnă „determinarea tipului la execuţie”?
34. Ce este o clasă abstractă?
35. Definiţi conceptul de interfaţă în limbajele obiectuale.
36. Care sunt diferenţele între supraîncărcarea funcţiilor, refedinirea funcţiilor în
contextul moştenirii şi „overriding”?
37. Ce este un design pattern?
38. Descrieţi design patternul „singleton”.
39. Descrieţi design patternul „iterator”.
III. Anexe
Bibliografie
1. G.C. Silaghi, Mircea Moca „Limbaje de Programare. Metode Obiectuale. Ghid teoretic
si practic”. Editia 2-a, Ed. Risoprint, 2008
2. Bruce Eckel, „Thinking in C++”, ed. Prentice Hall, 2000, vol 1.
3. Bjarne Stroustrup, „The C++ Programming Language”, Addison-Wesley,
4. Bazil Pârv, Al. Vancea, „Fundamentele limbajelor de programare”, 1996
5. D.L. Şerbănaţi, „Limbaje de programare şi compilatoare”, Ed. Academiei, 1987
6. Malcom Bull, „Student’s guide to programming languages”, HB Newnes, 1992
7. Aho A.V., Ullman J.D, Principles of Compiler Design, Addison-Wesley, 1977
Scurtă biografie a titularului de curs
Gheorghe Cosmin Silaghi este absolvent al secţiei Informatică Economică a Universităţii
Babeş-Bolyai Cluj-Napoca din anul 2000 si doctor in Stiinţe Economice – Cibernetică şi
Statistică Economică din anul 2005. Este lector universitar din anul 2006. Gheorghe
Cosmin Silaghi are o progătire vastă in domeniul informaticii fiind inginer in Stiinţa
Sistemelor si Calculatoarelor, absolvind programul de master of Science in Inteligenţă
Artificial ă a Universităţii Libere din Amsterdam. Pregătirea postdoctorală a titlularului de
curs include o participare de 18 luni in proiectul european FP6 CoreGrid fiind detaşat la
Rutherford Applenton Laboratory Anglia (2006-2007) şi Universitatea din Coimbra
Portugalia (2007). Activitatea de cercetare este completată de o serie de publicaţii
relevante realizate şi proiecte de cercetare conduse.
G.C. Silaghi este titularul acestui curs incepând cu anul 2005, după ce a realizat şi condus
laboratoarele la această disciplină incepând cu anul 2002. Experienţa de predare necesară
disciplinei este completată de experienţă practică efectivă, titularul de curs activând pe
rând ca şi programator, analist business şi consultant la diverse companii software.