30
Sep
2008

Programarea defensiva este o tehnica prin care se mareste robustetea unui program software in fata actiunilor neprevazute ale utilizatorului sau interactiunilor incorecte intre partile componente ale programului. Tinta programarii defensive este reducerea defectelor software, a complexitatii codului si securizarea acestuia.

In continuare voi prezenta o serie de metode prin care se poate programa defensiv in C++, majoritatea dintre ele folosite de catre mine personal. O parte m-au ajutat sa detectez rapid erori care in mod normal ar fi fost dificil de gasit, iar altele au facut mai usor de inteles si utilizat codul pe care il scriu.

Sunt convins ca prin utilizarea acestor metode am reusit sa evit multe ore petrecute intr-un debugger si sper sa ii ajute si pe cei care le vor citi in acest articol.

Strategii generale

Assert

Asertiunile pot oferi informatii importante pentru gasirea defectelor. Dupa experimentarea cu mai multe tipuri de utilizare ale asertiunilor, m-am oprit la cel exemplificat in urmatorul cod:

double SquareRoot(int value)
{
   if( value < 0 )
   {
      myASSERT(!"Can't calculate square root for values < 0");
      throw std::invalid_argument();
   }
   // do the actual work
}

Prin testarea conditionala combinata cu o asertiune se obtin urmatoarele avantaje:

  • conditia se verifica si in versiunea de debug si in cea de release si sunt semnalate erori in ambele cazuri
  • in versiunea de debug nu se executa de doua ori testul pentru conditie
  • asertiunea prezinta un mesaj informativ, care face mai usoara intelegerea erorii.

Tipul de asertiune de mai sus este unul dinamic, deoarece testul se executa la rulare. Un alt tip de asertiune este cea statica - evaluata in timpul compilarii, da erori de compilare in cazul in care conditia testata nu este adevarata.

O implementare de asertiuni dinamice mai sofisticata decat clasicul assert este SUPERASSERT al lui John Robbins sau POW2_ASSERT al lui Charles Nicholson, iar in cazul asertiunilor statice Boost staticassert este implementarea de referinta.

Cuvantul cheie const

const informeaza compilatorul ca o anumita variabila nu trebuie sa fie modificata sau ca o functie membru a unei clase nu modifica variabilele membru. Folosirea pe cat de mult posibil a const minimizeaza posibilitatea de aparitie a erorilor, deoarece respectarea sa este verificata de catre compilator si incercarile de modificare a unei variabile declarata constanta dau erori de compilare. Urmatoarele reguli le-am gasit utile in utilizarea const:

  • orice variabila care este doar citita trebuie sa fie declarata constanta
  • orice parametru care nu este modificat intr-o functie trebuie declarat constant
  • orice functie membru a unei clase care nu trebuie sa modifice variabile membre ale clasei trebuie declarata constanta
  • const_cast trebuie evitat, dar in cazuri deosebite se poate folosi mutable

RAII

"Resource acquisition is initialization" este un idiom C++ prin care durata de viata a unei resurse este asociata cu un obiect C++. Principalele sale beneficii sunt administrarea automata a resurselor si siguranta in fata exceptiilor, iar una dintre cele mai cunoscute utilizari sunt pointerii inteligenti precum std::auto_ptr sau boost::shared_ptr. O tehnica inrudita interesanta este Scopeguard.

Administrarea manuala a resurselor trebuie evitata, iar idiomul RAII trebuie folosit la maxim. Aceasta recomandare poate parea evidenta, dar de multe ori cand se scrie un cod administrarea resurselor se face manual pentru ca este mai convenabil, sau pentru ca acea bucata de cod sursa este foarte simpla: nu se lanseaza exceptii sau nu exista mai multe puncte de iesire.

Posibil, dar dupa ce bucata de cod trece prin mainile a mai multi programatori nu poate exista certitudinea ca fiecare dintre ei a fost atent la ce exceptii sunt lansate, daca toate punctele de iesire din functie elibereaza resursele alocate si asa mai departe. Cu RAII putem fi siguri ca resursele sunt eliberate intotdeauna!

Contracte de utilizare in functii

Semnatura unei functii poate fi privita ca un contract de utilizare intre creatorul functiei si apelant. Acest contract poate fi neclar si usor de incalcat (intentionat sau din greseala), sau poate fi robust si verificat de catre compilator. Iata cateva metode prin care semnatura unei functii poate fi transformata intr-un astfel de contract verificabil:

  • Parametrii care nu sunt modificati in interiorul functiei trebuie declarati constanti. Astfel se evita situatiile in care un parametru care nu ar trebui modificat este modificat si se evita si incertitudinile apelantului: "hmm, functia asta imi modifica obiectul pasat?"
  • Daca nu are sens ca valorile returnate sa fie modificate, ele trebuie declarate constante. Aceasta regula este incalcata de cele mai multe ori in numele convenientei.
  • Este util ca parametrii care sunt optionali sa fie pasati ca pointeri, iar parametrii obligatorii ca referinte sau normal, prin valoare. Pasarea parametrilor obligatorii ca pointeri forteaza verificari suplimentare in interiorul functiei si il incurca si pe apelant.
  • In cazul in care se transfera dreptul de proprietate asupra unei resurse, se poate folosi std::auto_ptr in loc de un pointer normal; transferul de proprietate devine parte din contract, este verificat de catre compilator si este usor de inteles pentru apelant. In cazul unei resurse partajate, se poate folosi boost::shared_ptr sau std::tr1::shared_ptr, care este parte a noului standard. Se evita astfel situatiile neplacute in care nu este clar cine ce resursa elibereaza.

Siruri de caractere

Limbajul C++ nu are cu adevarat un tip string, sirurile de caractere sunt doar o secventa continua de caractere, iar programarea cu siruri in C++ este predispusa la erori precum trunchiere, erori de terminare, erori off-by-one (implica scrierea in afara limitelor tabloului de caractere) sau unbounded copy (apare cand un sir de caractere cu o lungime nelimitata explicit este copiat intr-un sir cu lungime fixa). Potentialul pentru defecte este accentuat de faptul ca o foarte mare parte din prelucrarile pe care le face un program implica siruri de caractere.

Una dintre cele mai bune metode pentru evitarea erorilor in administrarea sirurilor de caractere este folosirea std::string si std::stringstream (impreuna cu variantele lor pentru wchar_t unde este cazul). Aceasta solutie nu poate fi insa aplicata in orice situatie, asa ca voi prezenta cateva metode pentru diminuarea riscului in folosirea sirurilor de caractere native:

  • C++ contine functii de lucru cu siruri de caractere nesigure, printre care se numara gets, strcpy, strcat sau orice alta functie in care nu se poate indica dimensiunea bufferului tinta. Optiunile sigure sunt fgets, strncpy, strncat si asa mai departe, sau familia de functii *_s() definita in ISO/IEC 24731.
  • Pentru formatarea unui sir de caractere in C, cea mai buna varianta este familia de functii *printf. Aceste functii sunt disponibile si in C++; flexibilitatea si rapiditatea lor le face sa fie preferatele programatorilor in fata iostreams. Iata doua exemple de probleme de utilizare a functiilor *printf intalnite in proiecte la care am participat:

    • Un sir parametrizat trebuia sa fie scris intr-un fisier si acest sir continea caractere % care trebuiau sa fie marcate cu \. Un caracter % nu a fost marcat, programul mergea bine in versiunea de debug, dar facea crash in cea de release.
    • In a doua situatie, un program compilat cu VC++ accepta si functiona cu specificatorul de format %s pentru siruri de caractere wide, dar compilat cu gcc facea crash la rulare, pentru ca se astepta la %ls.

    Concluzionand, problema cu functiile *printf(...) este ca sunt imprevizibile. Cea mai mica schimbare in sirul de format sau in tipul parametrilor poate duce la o eroare dificil de gasit. Vi s-a intamplat vreodata sa pasati un std::string in loc de std::string::c_str() ca parametru la sprintf? Nu exista nicio diferenta pana la rulare cand prima varianta va crapa (daca aveti noroc), iar cealalta va functiona. Personal folosesc si recomand boost format. std::stringstream este o alternativa mai putin flexibila din punct de vedere al formatarii, dar o prefer in fata orelor petrecute in fata unui debugger. Un aspect imprtant de retinut este ca operatiile nesigure pe siruri nu sunt doar defecte, ci si gauri de securitate.

  • Specificati cat mai precis tipul sirului de caractere.

    • Daca declarati un pointer la un sir de caractere literal, faceti-l constant: const char* sir = "exemplu". Mai tarziu, cand cineva incearca sa il modifice va primi o eroare de compilare (intotdeauna incearca cineva sa-l modifice).
    • Nu este nevoie sa specificati dimensiunea unui sir de caractere intializat. const char szExemplu[] = "ex" functioneaza perfect,dar daca se mentioneaza si lungimea exista doua probleme: este posibil sa fie scrisa gresit si trebuie modificata cand se modifica sirul de caractere.
    • Nu va bazati pe faptul ca tipul caracter are 8 biti, mai ales cand se foloseste un macrou pentru reprezentarea sa. Codul urmator are comportament diferit in functie de modul in care este definit macroul UNICODE:

      TCHAR sir[16];
      if( strlen(sirParametru) >= sizeof(sir) ) {...}
      
  • Folosirea boost::scoped_array, boost::shared_array sau chiar std::vector pentru siruri alocate dinamic il scuteste pe programator de multa bataie de cap in ceea ce priveste administrarea memoriei.

Utilizarea memoriei

Problemele de administrare a memoriei sunt dificil de rezolvat pentru ca acest tip de bug poate trece neobservat in teste sau poate cauza alte defecte in zone complet diferite de cod. Compilatoare precum gcc sau VC++ au optiuni prin care pot activa procedee de verificare a accesului la memorie, inclusiv alocarea si dezalocarea. Este de dorit ca fiecare programator sa stie ce fac aceste optiuni si cum il pot ajuta. Pe langa functionalitatile pentru detectarea erorilor puse la dispozitie de compilator, recomand si familiarizarea cu programe precum AppVerifier, Valgrind sau PC-Lint.

Din punct de vedere al programarii defensive, poate cea mai cunoscuta tehnica este atribuirea valorii NULL unui pointer dupa ce a fost eliberata memoria catre care indica. In acest fel se evita erorile de tip double-free si incercarea de accesare a unui pointer dupa ce memoria la care indica a fost eliberata se soldeaza cu o exceptie, mult mai bine decat comportamentul nedefinit.

Am observat rezultate incurajatoare si cu urmatoarele tehnici:

  • Crearea unei strategii de administrare a memoriei: ce functii de alocare/dezalocare se folosesc, posibilitatea inglobarii lor in obiecte allocator. Un allocator poate impune si in acelasi timp incapsula functiile de alocare si dezalocare si poate contine si cod special pentru debugging.
  • Utilizarea pointerilor inteligenti si eliminarea pe cat posibil a alocarii/dezalocarii manuale.
  • Evitarea alocarii si dezalocarii de resurse la niveluri diferite. ex: functii care aloca resurse ce trebuie dezalocate explicit de apelant.
  • Utilizarea stivei unde este posibil in loc de a aloca memorie explicit:

    CPerson *john = new CPerson;
    employees.get(john,"John");
    std::cout << john->age();
    delete john;
    

    versus

    CPerson john;
    employees.get(&john,"John");
    std::cout << john.age();
    
  • Urmatorul pas dupa obiecte allocator este utilizarea de obiecte factory (ex: abstract factory design pattern). Un factory combinat cu pointeri inteligenti poate administra complet un obiect pe toata durata sa de viata. Poate implementa inclusiv strategii de caching sau alte optimizari, simplificand in mod semnificativ sarcina programatorului utilizator.

Lucrul cu exceptii

Am constatat ca de departe cel mai important aspect in lucrul cu exceptii este crearea unei ierarhii de exceptii bine definite. O mare parte din puterea exceptiilor este ca pot fi prinse in mod ierarhic.

Intr-o iererhie de exceptii bine definita, catch(std::exception&) ar trebui sa poata prinde orice exceptie relevanta pentru program. Iata un minighid pentru exceptii in C++:

  • Exceptiile definite in proiect trebuie sa aiba drept clasa de baza pe std::exception sau pe clasele sale mostenitoare. Utilitate: se poate crea un exception handler global.
  • Trebuie aruncate doar exceptii de tip obiect definit explicit in program ca exceptie (vezi punctul 1). Utilitate: exceptiile contin un minim de informatie utilizabila, pot fi prinse in mod ierarhic.
  • Exceptiile trebuie prinse la un nivel la care se poate actiona pe baza informatiilor din ele. De exemplu catch(...) nu trebuie folosit decat in cazuri extraordinare, in care nu conteaza tipul exceptiei pentru ca se executa aceleasi instructiuni.

Concluzii

Programarea defensiva este asemanatoare condusului defensiv: un efort suplimentar menit sa previna posibile dificultati. Merita sa depuneti acest efort?

Cand pentru prima oara o tehnica de programare defensiva a ajutat la gasirea unui defect care mie imi scapase, am inteles imediat ca descoperisem o unealta valoroasa. Din ce am observat, cu cat este utilizata mai mult aceasta unealta, cu atat devine mai naturala pentru programator, pana in clipa in care acesta va ajunge sa o foloseasca instinctiv, fara niciun efort.

In cele din urma, raspunsul la intrebarea de mai sus difera de la persoana la persoana; trebuie sa mentionez insa ca nu am intalnit pe nimeni care sa fi abandonat aceasta tehnica dupa acel moment de "a-ha" in care i-a ajutat sa identifice un defect.

Recomandarea mea este asadar sa incercati sa introduceti treptat programarea defensiva in codul pe care il scrieti, incepand cu partile mai simple. Spor la codare si succes!