Mihai BUDIU - mihaib@pub.ro
februarie 1996
Voi începe cu un citat:
As soon as we started programming, we found to our surprise that it wasn't as easy to get programs right as we had thought. Debugging had to be discovered. I can remember the exact instant when I realized that a large part of my life from then on was going to be spent in finding mistakes in my own programs.
-- Maurice Wilkes discovers debugging, 1949
În traducere asta ar suna cam așa:
Imediat ce am început să programăm am descoperit spre surprinderea noastră ca nu era așa de ușor să facem programe bune pe cît crezusem. Depanarea (debugging) trebuia să fie inventată. Îmi pot aminti cu precizie momentul exact cînd am realizat că o mare parte din viața mea de atunci încolo urma să fie petrecută în a găsi greșeli în propriile mele programe.
De asemenea -- pentru contrast -- îmi place să citez următorul fragment din manualul care însoțește programul MetaFont, scris de marele informatician Donald Knuth:
On January 4, 1986 the ``final'' bug in Metafont was discovered and removed. If an error still lurks in the code, Donald E. Knuth promises to pay a finder's fee which doubles every year to the first person who finds it. Happy hunting.
Adică:
Pe 4 ianuarie 1986 ``ultimul'' bug (defect) din Metafont a fost descoperit și scos. Dacă o eroare mai zace prin cod, Donald E. Knuth promite să plătească o sumă care se dublează în fiecare an celui care o găsește. Vînătoare plăcută.
Cîți dintre dumneavoastră au reușit să scrie un program mai lung de 2000 de linii sursă (într-un limbaj de nivel înalt) care să exhibe același îngrijorător simptom al perfecțiunii?
Cu riscul de a vă agasa voi mai cita un monstru sacru al informaticii, de data asta însă din memorie. Edsger Dijkstra spunea în cuvîntarea sa făcută cu prilejul decernării premiului ``Turing'' al prestigioasei societăți americane ACM (Association for Computing Machinery), cuvîntare care dealtfel se numea ``The Humble Programmer'' (Programatorul Umil), că un bun programator trebuie să recunoască faptul că sarcina ce și-a asumat-o îl depășește cu mult datorită complexității sale, și ca atare trebuie să încerce cît mai mult să o reducă la bucăți mai mici, cît mai ușor de manevrat.
Deși aparent legăturile dintre cele trei citate de mai sus sunt cam șubrede, sper că textul care urmează va arăta că de fapt acestea sunt trei fețe ale aceluiași poliedru.
În rezolvarea unei probleme cu calculatorul se pot distinge mai multe etape (chiar dacă ele nu sunt distincte temporal): analiza problemei, elaborarea algoritmului, implementarea, documentarea, testarea, etc. Acest articol se ocupă de una singură dintre ele, care dealtfel este foarte puțin dependentă de problema care trebuie rezolvată: presupun că știu algoritmii, tot ce trebuie să fac este să-i implementez într-un limbaj de programare oarecare. Aparent banală, etapa aceasta se dovedește adesea foarte intricată. De fapt acest lucru îl arată și primul citat din introducere.
Cele mai multe considerații care urmează sunt independente de limbajul ales.
Un program trebuie să fie scris în așa fel încît să poată fi citit de către oricine.
Programele sunt făcute în așa fel încît părțile sunt strîns dependente. Trebuie ca cele scrise într-un loc să fie folosite într-altul (și eventual refolosite). Mintea unui om normal nu poate reține toate amănuntele din textul unui program, de aceea trebuie să-i dăm un ajutor.
Sfatul acesta este clar obligatoriu cînd se dezvoltă soft în echipă, pentru că ceea ce scrie unul trebuie să fie folosit de altul. Paradoxal este că el este extrem de oportun chiar cînd întreg procesul de scriere este făcut de un singur programator. Nu vi s-a întîmplat niciodată să vă uitați la o funcție scrisă în urmă cu o lună și să vă mirați că ați putut scrie așa ceva? Sau să vă întrebați ce face? Acel ``oricine'' din sfat poate fi deci un ``alter ego'' al dumneavoastră.
Aparent scrierea îngrijită a unui program consumă prea mult timp.
Pentru programe scurta acest lucru poate fi adevărat. Pentru programe la care lucrați mai mult de o săptămînă timpul cheltuit cu o scriere îngrijită este recuperat înzecit în fazele ulterioare ale dezvoltării (depanare, extindere, întreținere). Dacă nu credeți, încercați măcar o dată.
Fă bucățile în așa fel încît să înțelegi ușor ce face fiecare fără a trebui să știi cum face.
Domnul Dijkstra (în olandeză se citește aproximativ ``daicstra'') recomanda o tactică veche de pe vremea lui Cezar: ``divide et impera'': ``împarte și stăpînește''. Așa e bine să scrieți și programele: în bucăți cît mai independente unele de altele, care interacționează intre ele în moduri cît se poate de clare și simple, și care nu se influențează ``pe la spate''.
Din fericire toate limbajele moderne de programare aduc un suport extrem de eficace acestei metode de scriere: funcțiile, procedurile și modulele.
Împărțiți deci programul dumneavoastră în astfel de părți cît mai des cu putință. Faceți părțile logic independente; funcțiunea fiecăreia trebuie să fie extrem de clară și cît se poate de limpede enunțată. De exemplu, o funcție care caută ceva într-un șir nu trebuie să facă și ordonarea șirului. Faceți două funcții independente pentru asta, una de căutare și una de sortare.
Dacă limbajul permite module, atunci faceți-le în fișiere separate, ale căror nume să fie suficient de clare. Grupați în fiecare modul numai funcții și variabile înrudite. Dacă programați într-un limbaj orientat pe obiecte, implementați fiecare clasă într-un modul separat, cu toate metodele ei. O să știți apoi unde să le căutați, și apoi o să le puteți reutiliza cu ușurință în alte programe.
Cheia economiei de timp este asta: cînd vrei să folosești o funcție trebuie să știi numai ceea ce face; nu trebuie să o citești în întregime ca să vezi cum face acest lucru. De aceea funcția este mult mai ușor de folosit, și apoi poate fi la nevoie rescrisă în cu totul alt fel, fără a influența cîtuși de puțin restul programului. (De exemplu nu contează dacă sortarea este bubble sort sau quick sort, totul este să fie tot o sortare. Funcțiile care folosesc funcția de sortare nu sunt interesate de modul în care aceasta se face). Un alt uriaș beneficiu al acestei relative independențe între feluritele părți este că în momentul în care un bug este descoperit și scos dintr-una, celelalte vor rămîne neschimbate, pentru că nu se bazau pe felul în care lucra acea parte, ci doar pe ceea ce ea făcea.
Cîte funcții sau linii de program trebuie să fie într-un modul? Asta este destul de mult o chestie de gust, dar editorul de texte pe care îl folosiți poate influența mult alegerea. Dacă aveți 24 de linii de ecran (din care 3 folosite la borduri și meniuri) este greu să scrieți fișiere mari, pentru că vă plimbați cu greu prin ele. Alta este situația dacă aveți 42 sau 80 de linii.
Un editor inteligent reduce handicapul unor fișiere mari, pentru că vă permite să va mutați căutînd automat aparițiile funcțiilor și variabilelor.
Eu personal mă descurc cel mai bine cu fișiere între 300 și 700 de linii, conținînd pînă în 15 funcții.
Principiul minimei vizibilități cere ca un obiect să nu fie vizibil decît părților din program care au cu adevărat nevoie de el. Să vedem niște aplicări ale lui:
Datele trebuie separate cît se poate de mult de corpul programului.
O variantă a acestei reguli este ``în sursa programului nu trebuie să apară nici un număr cu excepția lui 0, 1, sau -1''. Constantele (în particular cele numerice) se mai numesc și ``magic numbers'' (numere magice). Ele trebuie evitate întotdeauna.
Metoda comună este de a da nume simbolice constantelor undeva la începutul fiecărui modul, sau în fișiere speciale (cu const în Pascal și C, cu #define în C, etc.). Numele dat unei constante are multe avantaje, care sunt expuse în mai toate cursurile de programare. Noi vom reaminti decît pe unul: o constantă face programul mult mai ușor de citit. Aceasta este o aplicare a principiului minimei vizibilități: știi numai numele constantei (care trebuie să arate la ce folosește ea), și nu valoarea ei!
Iată un exemplu:
for i:=1 to 128 do persoana[i].nume := '';
și tradus:
for p:=1 to NumarDePersoane do persoana[p].nume := '';
Nu e mai clar a doua oară?
Cum spuneam părțile din program interacționează te miri cum. Cel mai adesea interacțiunile se fac prin variabile globale, a căror valori pot fi modificate de o funcție și citite de alta.
De aceea este bine să transmitem funcțiilor (procedurilor) toate valorile de care au nevoie prin argumente și să nu le lăsăm să modifice variabile globale.
Dacă vi se pare greu să programați fără variabile globale, aflați că există limbaje de programare în care nu există variabile de loc, și totuși se pot face foarte multe lucruri (un exemplu este Prolog; despre Prolog se crede îndeobște că are variabile, însă o analiză atentă va arăta că ceea ce în Prolog se numește variabilă nu folosește de loc la același lucru ca o variabilă în sensul uzual al limbajelor imperative ca Pascalul).
În realitate este foarte greu adesea să ne descurcăm fără variabile globale, pentru că unele funcții fac modificări atît de masive încît ar trebui să aibă zeci de parametri și să returneze tot atîtea valori (ceea ce multe limbaje nici nu permit, dealtfel!). Tocmai pentru a circumveni aceste dezavantaje au fost inventate limbajele orientate pe obiecte (alde C++), în care în loc să dai unei funcții argumente, grupezi argumentele la un loc într-un obiect (un fel de record din Pascal); funcția însăși devine un cîmp al obiectului (se va numi ``funcție membru''), și în loc să chemi funcția cu cîmpurile obiectului ca argumente, rogi obiectul să-și aplice singur funcția.
Această reducere la maximum a vizibilității din limbajele orientate pe obiecte (căci cîmpurile unui obiect sunt în mod normal accesibile numai funcțiilor care-i sunt membre) se numește ``încapsulare'' și constituie unul din avantajele majore ale programării orientate pe obiecte față de cea ordinară.
Cu disciplină însă se pot aplica beneficiile încapsulării și în programe scrise în limbaje fără astfel de construcții (C, Pascal), cu foarte mult succes.
Unii separă părțile din programe prin comentarii baroce ca:
{******************************} {========intrare/iesire========} {******************************}
Ei bine, puține spații albe judicios plasate fac mult mai mult bine lizibilității programului. Iată care sunt recomandările mele în ceea ce le privește:
for(i=0;i<ultim;i++) if(!a[i]) total++;
față de:
for (i = 0; i < ultim; i++) if ( ! a[i] ) total++;
for (i = 0; i < ultim; i++) if ( ! a[i] ) total++;
venit(persoana[pozitie_curenta].nume, departament[persoana[pozitie_curenta].cod_departament], indexare_salarii);
venit_max = persoana[pozitie_curenta].venit + crestere_salariu;
copii := numar_copii( indice[disc_curent, compartiment, raft_curent], eticheta);
i := 1; c := 'y'; while (i < maxN) and not (a[i] < 0) and not (c = 'n') do begin i := i + 1; read(f, c) end
ci:
i := 1; gata := false; c := 'y'; while not gata do begin if i >= maxN then gata := true; if a[i] < 0 then gata := true; if c = 'n' then gata := true; if not gata then begin i := i + 1; read(f, c) end end
Regula de bază este ca o instrucțiune care depinde de altă instrucțiune să fie scrisă puțin mai la dreapta. De pildă if arată așa: if <expresie> then <instructiune1> else <instructiune2>. <instructiune1,2> depind de if. Un if se scrie deci așa:
if TrebuieSters then StergeJucator(BazaDeDate, NumarulDeOrdine) else calificari(BazaDeDate, NumarulDeOrdine);
for i := 1 to ultim do begin CalculeazaNota(i); AlegePostura(i) end;
sau
for i := 1 to ultim do begin CalculeazaNota(i); AlegePostura(i) end;
sau
for i := 1 to ultim do begin CalculeazaNota(i); AlegePostura(i) end;
sau
for ( i = 1; i <= ultim; i++) { calculeaza_nota(i); alege_postura(i); }
repeta: switch(raspuns) { case 'd': stiut = DA; break; case 'n': stiut = NU; break; case '?': interogare(); goto repeta; default: /* alt raspuns */ return; }
if e1 then cutare1 else if e2 then cutare2 else if e3 then cutare3 else cutare3
așa:
if e1 then cutare1 else if e2 then cutare2 else if e3 then cutare3 else cutare3
pentru că sunt echivalente cu un case din Pascal (switch din C) -- adică un if cu mai multe condiții, care au aceeași importanță.
Comentariile sunt într-adevăr extrem de utile pentru a ușura înțelegerea funcționării unui program. Rolurile lor sunt multiple, și modul în care se pot folosi extrem de variat.
În primul rînd există două feluri de comentarii:
Primele ocupă o linie (sau mai multe) de program în întregime și explică ceva din funcționarea lui. Iată unde sunt extrem de utile:
for i := 1 to sfirsit do { verific toate pozitiile } verifica( pozitie[i] );
Comentariile de sfîrșit de linie în general explică operația făcută pe linia curentă. Ele sunt mult mai greu de întreținut, pentru că schimbarea uneia din linii strică întreaga lor aliniere.
var persoane, { numar de participanti } locuri, { numarul de scaune la masa } LocCurent: integer; { locul pe care incerc sa pun }
/* elementele nu pot fi 0 */ for ( i = 0; i < nr_elem; i++ ) { if ( a[i] == 0 ) break; /* eroare! opresc procesarea */ if ( a[i] < a[i+1] ) swap (&a[i], &a[i+1]); }
function arata(x, y, {coordonatele din stinga-sus} lungime, latime: integer): boolean;
if ( functionare == INTERACTIV ) { int alege; printf("\a\a\a"); /* un pic de galagie */ alege = meniu_principal(); /* selectia */ switch (alege) { case FISIER: fisier(); /* toata intrarea/iesirea */ break; case CALCUL: calcul(); break; default: return 0; /* nimic valid -> termin */ } } else { /* aici functionare != INTERACTIV */ ... }
Dacă nu poți să-i dai un nume unei variabile înseamnă că acea variabilă nu este bună. Reconsideră arhitectura întregului program.
Într-adevăr, nu trebuie să faci nici un efort ca să-ți aduci aminte ce reprezintă o variabilă; numele ei trebuie să-ți spună tot ce te interesează.
Nu faceți niciodată economie înghesuind în aceeași variabilă mai multe valori, depinzînd de context! Fiecare variabilă trebuie să reprezinte un singur lucru în fiecare clipă. Compilatoarele moderne sunt suficient de inteligente ca să transforme cele două variabile în una singură dacă asta se poate! (De fapt le chiar ușurați munca neînghesuind mai multe lucruri laolaltă).
Numele unei variabile trebuie să arate exact ce reprezintă valoarea ei.
Numele variabilelor, funcțiilor, procedurilor, argumentelor lor, trebuie să fie de asemenea ușor de reamintit. Dacă scrii un program și trebuie mereu să te duci la prima pagină ca să vezi dacă o variabilă se cheamă max_n. sau max_numere sau maximum_numere treaba merge destul de greu. În principiu nu se folosesc prescurtări. Un nume de variabilă ca x sau total este absolut inutil, căci orice poate fi x. Scrieți x_fereastra sau total_venit de pildă.
Nu trebuie să va fie lene să tastați. De altfel un editor foarte bun (ca de exemplu GNU Emacs) vă ajută foarte mult (Emacs la apăsarea tastei M-/ încearcă să completeze cuvîntul curent cu un sufix care se regăsește undeva prin text. De pildă dacă am definit variabila complexitate_totala. atunci probabil ajunge sa tastez com și apoi M-/ ca Emacs să completeze întreg numele).
Nu scrieți funcții care în funcție de un argument fac două lucruri diferite:
int operatie(FILE * f, struct persoana * p, int actiune) /* daca actiune = 0 -> cauta de cite ori apare persoana p in fisier * daca actiune = 1 -> sterge toate aparitiile p din f */
Scrieți două funcții, pe care le chemați dintr-un if sau switch (case).
Este util să folosiți notații diferite pentru constantele numite pentru a le distinge de variabile. În C, în care contează literele mari/mici, aceste constante au de obicei numele scrise numai cu majuscule.
Un program cu nume de variabile eficient alese are nevoie de mult mai puține comentarii, și este mult mai ușor de citit și întreținut. Cineva spunea că pot fi citite la gura sobei, ca o carte bună. Am avut ocazia să citesc astfel de programe și trebuie să recunosc că am învățat extrem de mult din ele. Pentru curioși recomand doi monștri sacrii ai programării cristaline, Andrew S. Tanenbaum (autorul sistemelor de operare MINIX și Amoeba) și Richard Stallman (autorul compilatorului GCC și al lui GNU Emacs).
Dacă scrieți module care exportă multe variabile sau funcții puteți prefixa toate obiectele unui modul cu un text care le arată proveniența și semnificația. De exemplu toate procedurile care lucrează cu ferestre se vor numi ceva de genul Win_xxx (win de la ``window''.. Aceasta complică și simplifică deopotrivă unele lucruri. E mai simplu pentru că poți avea o procedură make și pentru ferestre, și pentru stive, numai că una se cheamă Win_make iar alta Stk_make.
Dacă vă decideți pentru prefixe, în general ele sunt preferabil sufixelor. Adică e mai bine Win_make decît make_win. pentru că prefixele se văd mai ușor. În nici un caz nu amestecați într-un program cele două feluri de numire.
Apropos de amestecare, faceți la început niște decizii de numire omogenă a obiectelor cu care operați. De exemplu: ``toate comentariile sunt în engleză'', sau ``toate numele de proceduri sunt cuvinte englezești'', ș.a.m.d. În program coexistă greu o procedură numar_persoane cu una first_person. (Dacă nu știți engleză lucrurile sunt ceva mai simple.)
``Nu avea încredere în nimeni, nici măcar în tine însuți!'' Respectarea acestei reguli aduce niște beneficii greu de imaginat. Ce înseamnă ea pentru un programator?
void * aloca_memorie(size_t marime) /* aloca memorie. eroare fatala daca nu mai este memorie */ { void * p; /* aici alocam temporar */ p = malloc(marime); if (p == NULL) { /* standardul spune ca malloc intoarce NULL numai * daca nu a reusit sa aloce cantitatea indicata */ fprintf(stderr, " Alocare nereusita!\n"); exit(1); } return p; }
sau (în TURBO-Pascal):
procedure deschide_fisier(var f:text; nume:string); { deschide un fisier; verifica erorile } begin {$i-} { inhiba erori de executie pentru intrare-iesire } assign(f, nume); reset(fis); {$i+} { inversul lui $i- } if ioresult <> 0 then begin writeln('deschide_fisier: Nu pot deschide fisierul ', nume); halt end end;
Metoda universală este următoarea: definiți constanta booleană (sau întreagă) DEPANARE, pe care o faceți true. Apoi în program verificați mereu astfel:
if DEPANARE then begin { verificam daca locasele sunt bine initializate } for i := 1 to spatii do if locas[i].urmator = nil then begin writeln('Locas cu "urmator" nil la indicele ', i); halt end end
Testele pentru locașe se vor efectua numai dacă DEPANARE este true. Dacă faceți DEPANARE false, testele dispar cu desăvîrșire! Puteți astfel să vă depanați programul, iar cînd merge să-l scurtați. Un compilator cu optimizări va observa că liniile de test nu se vor putea efectua niciodată (pentru că DEPANARE este o constantă), și le va scoate cu totul din program. (Pe de altă parte unele teste este înțelept să rămînă pentru totdeauna în program; nu se știe niciodată pentru ce date de intrare programul o ia razna. Este recomandabil să găsești o eroare cît mai repede și să urlii că ai găsit-o decît să o ignori o vreme și ea să facă prăpăd în liniște. Mulți utilizatori vor aprecia.)
Tocmai din această cauză nu orice eroare trebuie semnalată ca fiind fatală. Nu poți să faci o împărțire la 0? Anunță doar pe funcția care te-a chemat cu argumente greșite că ceva nu e în regulă. Cum ar fi un program de calculator de buzunar care s-ar opri la o astfel de eroare? Penibil! Eroarea trebuie doar să fie anunțată și procesarea continuată.
Poate vom reveni cu un articol special despre tratamentul erorilor, pentru că este un subiect extrem de interesant, și foarte prost tratat în literatură. O eroare se poate adesea propaga în sus pe un lanț de funcții care s-au chemat între ele (mai ales dacă una era recursivă) la distanțe extrem de mari de locul producerii. Cum se face asta cu grijă, cum se semnalează erorile cel mai simplu, asta merită discutat mai pe-ndelete.
Donald Knuth (unul din cei cu citatele din introducere) este unul dintre adepții a ceea ce se numește ``litterate programming''. Acesta este un sistem (numit Web) în care programul și documentația sa se scriu dintr-un singur foc, apoi cu două compilatoare diferite se obțin executabilul și cartea de descriere a produsului. Paradoxal, această metodă mărește productivitatea și fiabilitatea, pentru că îți poți introduce explicațiile detaliate chiar în corpul programului în timp ce-l scrii, gîndind mai ușor atît asupra programului cît și documentației.
Dacă nu spuneți cum face ceva un program de-al dumneavoastră, trebuie totuși să spuneți ce face. Chiar dacă nu scrieți programe pentru vînzare este un obicei extrem de util să scrieți scurte documentații pentru utilizatorii lor sau cei care vor să le modifice. Un model împămîntenit este pagina de manual interactivă din UNIX (sau comanda ``help'' din DOS, într-un fel). O descriere a programului dumneavoastră ar trebui să conțină următoarele informații:
Dacă trebuie să folosiți trăsături ale limbajului care nu sunt standard (de exemplu în Turbo C gotoxy(), atunci nu le chemați niciodată direct; scrieți pentru ele ``wrappere''. Astfel de wrappere ar trebui să asigure o comportare uniformă a funcțiilor, chiar dacă bibliotecile de care dispuneți se schimbă.
De exemplu dacă faceți un program pe PC care lucrează cu mai multe culori, dacă vreți să meargă bine și pe plăci monocrome și color scrieți o funcție culoare_text() cam așa:
void culoare_text(int culoare) /* alege culoarea textului in functie de numarul de culori disponibile (2 sau 16) */ { int cul_reala; /* MONOCROM este aflat de exemplu intrebind utilizatorul la inceputul programului */ if (MONOCROM) cul_reala = culoare / 8; /* daca avem numai 2 culori pastram numai primul bit */ else cul_reala = culoare; textcolor(cul_reala); }
Dacă vă concepeți programele într-un mod cît mai general vor deveni adesea și ceva mai ușor de scris, și cu siguranță foarte ușor de întreținut și ``portat'' (mutat de la un compilator la altul).
Subiectul este foarte generos, iar tratarea pe care i-am dat-o aici este departe de a fi exhaustivă. Numai experiența vă va convinge de utilitatea unei discipline în actul programării. Deci spor la treabă!