» »

Šablone v C++

Ta vodič je namenjen vsem, ki se že znajdete v C++, obvladate C, pa vam še na misel ni prišlo, da bi kdaj uporabili C++ in pa seveda vsem ki vam je pri srcu kateri od enostavnejših jezikov ter se sprašujete zakaj sploh kdo še sili v programiranje s C++. Če vam jezik ni domač bi bilo dobro začeti tukaj, drugače pa kar nadaljujte z branjem.


Kaj sploh so šablone?

Poglejmo kaj nam o šablonah (ang. templates, tudi kalupi, predloge) pove naš zvesti slovarček:
A pattern or gauge, such as a thin metal plate with a cut pattern, used as a guide in making something accurately, as in woodworking.

Je ta definicija nekoliko lesena? Brez skrbi, Wikipedia nam priskoči na pomoč:
A template is some form of device to provide a separation of form or structure from content.

To je že bolje. V šablone lahko pretvorimo posamezne funkcije in razrede ter funkcije znotraj razredov. S tem jim okvirno določimo strukturo, podrobnosti glede vsebine pa nas zanimajo šele kasneje.

Wikipedia pa nam postreže tudi z bolj natančno definicijo, ki se nanaša le na programiranje:
Also known as parameterized types, templates allow the programmer to save time and space in source code by simplifying code through overloading functions with an arbitrary type parmeter.
Čisto po C++ovsko povedano pa bi lahko rekli, da so šablone funkcije, razredi in funkcije znotraj razredov, ki so parametrizirani glede na tip ali na konstanto.


Super ampak v čem je smisel?

Prebili smo se do glavnega namena šablon, generičnega programiranja. To je programiranje, kjer koda ni odvisna od uporabljenega podatkovnega tipa, dokler ta zadostuje nekaterim pogojem. Tudi to morda zveni znano? Res je, ideja ni nova, vsaj za tiste ki smo že kdaj poškilili v kaj podobnega Pythonu. Je pa v nasprotju s standardnim programiranjem v C, Pascalu in Javi, kjer npr. vsaki funkciji točno določimo tipe parametrov, tip rezultata in tipe vmesnih spremenljivk. Šablone so se sčasoma razvile in iz generičnega programiranja so nastali še drugi načini uporabe, kot sta statični polimorfizem in metaprogramiranje. Pravzaprav uporabnost šablon še vedno ni popolnoma izkoriščena, zato ker tudi njihovi idejni očetje še niso odkrili vseh njihovih trikov.


Besede so poceni, pokaži mi kodo!

Vse skupaj se sliši precej kompleksno zato raje stopino na trdna tla in si poglejmo, kako stvar izgleda v praksi. Kaj pomeni parametrizacija glede na tip? Recimo, da veliko uporabljamo funkcijo abs (matematična funkcija absolutne vrednosti). Uporabljamo tudi različne numerične tipe, od osnovnih int, short int, float, double, ... pa do sestavljenih Complex, VeryLong, Rational, ... (pri čemer sem si te zadnje izmislil).


"n00b" pristop

Prva možnost je, da za vsak tip napišemo posebno različico funkcije abs:

double abs(double num) {
      return (num > 0 ? num : -num);
   }

   int abs(int num) {
      return (num > 0? num : -num);
   }

Ampak to je zelo veliko pisanja in davek plačujejo naši prsti ter tipkovnica, ko ob lovljenju hroščev udrihamo po njej. No po pravici povedano je tukaj dosti več kopiranja in lepljenja (torej dolgčas). Poleg tega je taka koda dolga in zoprna za vzdrževanje. Samo pomislite koliko vrstic kode bi morali popraviti, če bi se svetovni matematiki združili in spremenili definicijo absolutne vrednosti!

Združevanje podatkovnih tipov

Druga možnost je združevanje podobnih podatkovnih tipov. Na primer namesto da bi pisali funkcije s parametri tipa float, int, char, unsigned short, int, spišemo le double abs(double num). Zakaj je to mogoče? Ker lahko vse prej omenjene tipe popolnoma brez izgube informacije pretvorimo v stari dobri double. S tem si prihranimo nekaj pisanja, plačamo pa pri hitrosti izvajanja, saj bo v vseh primerih, kjer ne bomo rabili doubla (ampak npr. le int) potrebno veliko dodatnega računanja (pretvorba iz int v double, operacija ali dve s plavajočo vejico in potem spet pretvorba iz double v int).

Makri

Tretja možnost pa je uporaba makrov:

define abs(A) ((A) > 0? (A) : -(A))

Dobri strani makrov sta njihova prilagodljivost in dejstvo, da se vpišejo naravnost v kodo. Vsak abs(nekaj) se namreč med predprocesiranjem c++ datotek zamenja s samo definicijo makra. V C++ žargonu bi rekli, da se makro vedno zapiše inline.

In kaj je torej narobe z makri? Kljub podpori za večvrstične makre so bili ti vedno namenjeni pisanju kratkih enovrstičnih stavkov. Poleg tega se še nesramni in se vrinejo tudi v kodo, kjer jih sploh nismo želeli (npr. v metodo abs nekega poljubnega razreda). Ko torej definiramo tak makro ne moremo več dopisati nobene druge abs funkcije (niti globalne, niti znotraj razreda).

Morda bi si želeli definirati posebno funkcijo za nek res poseben podatkovni tip:

unsigned int abs(unsigned int num) {
      // unsigned tipi niso nikoli manjĹĄi od 0

      return num;
   }

To je sedaj nemogoče (pustimo debato o tem ali je sploh smiselno), ker se nam bo makro abs vrival tudi v to kodo ter jo pokvaril. Rešitev je v preimenovanju funkcije ali makra. Pri čemer izgubimo lepoto in enostavnost uporabe istega imena za vse tipe.

Zavijmo na tem mestu malo iz teme ter zaključimo z makri. V C++ je nenapisan dogovor, da se morajo imena makrov razlikovati od ostale kode, da jih ponesreči ne uporabimo (na primer z velikimi črkami (ABS)). Poleg tega velja, da se jih ne uporablja nikjer, razen tam, kjer so res nujno potrebni. V našem primeru pa se popolnoma nadomestljivi.


Šablonske funkcije

Nobeden izmed prejšnjih pristopov ni tista prava stvar. Sprijazniti se moramo s kodo, ki je obsežna in težka za vzdrževanje, počasna, nevarna in grda ali pa za njo velja kar vse od naštetega. C++ nam ponuja način, kako prvi pristop spremenimo v programerju prijaznega (vse delo prestavimo na prevajalnik - saj zato ga vendar imamo). Leni programerji bomo tako prevajalniku podali šablono, on pa bo iz nje naredil vse funkcije, ki jih bomo rabili.

template<class T>
   T abs(T num) {
      return (num > 0? num : -num);
   }

Z deklaracijo template<class T> povemo, da ima funkcija abs en šablonski parameter, ki se imenuje T in je tipa class (razred). Parameter T torej ne more biti spremenljivka tipa int ampak kar int. Ali pa float, double, Complex in tako naprej. Pa si poglejmo še kako tako funkcijo uporabimo.

int main() {
      int a = -10;
      int b = abs(a);
      float c = 20.0f;
      float d = abs(c);
   }

Kako je možno, da lahko kličemo funkcije, ki jih sploh nismo definirali? Prevajalnik bo videl stavek b = abs(a) in ugotovil da rabi funkcijo abs(int). Ker take funkcije ne bo mogel najti bo pogledal, ali ima ustrezno šablono da z njo naredi novo funkcijo (šablonski parameter T bo preprosto zamenjal z int).

// prevajalnik najde šablono in jo poskusi prevesti v spodnjo kodo:

   int abs(int num) {
      return (num > 0? num : -num);
   }

Taka koda se uspe prevesti in zato je prevajalnik srečen (mi pa še toliko bolj).

Kako pa z objekti?

Pa bodimo malce naivni in poskusimo enako narediti še z objektom.

class Dummy {
      int number;
   };

   int main() {
      Dummy a;
      Dummy b = abs(a);
   }

Opa! Tako pa ne gre. Zakaj ne?

 // prevajalnik najde šablono in jo poskusi prevesti v spodnjo kodo:

   Dummy abs(Dummy num) {
      return (num > 0? num : -num);
   }

Dummy-ja se ne da primerjati z 0 (oziroma ne obstaja operator >), prav tako nimamo funkcije, ki bi znala izračunati -Dummy. Prevajalnik torej ni več srečen čeprav nam program z elementarnimi podatkovnimi tipi dela brezhibno.

Kaj pa če bi želeli šablono malo prilagoditi za posebne primere?

Na primer za razred Rational (ki ga bomo definirali spodaj) bi uporabili prav posebno funkcijo za absolutno vrednost.

   class Rational {
      public:
         double numerator;
         double denominator;
         // enostaven konstruktor

         Rational(double num, double denom) {
            numerator = num;
            denominator = denom;
         }
         // ...

   };

   // funkcija, ki bo prevzela računanje absolutnih vrednosti za racionalne številke

   template<>
   Rational abs(Rational num) {
      return Rational(abs(num.numerator), abs(num.denominator));
   }

Primeru zgoraj se reče specializacija šablone (template specialization). Specializirali smo abs - prej nedoločen tip T smo zamenjali s točno določenim tipom Rational. To smo izpeljali tako, da smo class T znotraj definicije šablone odstranili, povsod drugod pa zamenjali z Rational. Vsakemu tipu (ampak res čisto vsakemu) lahko določimo posebno obliko šablone, ki se mu prilagodi. Morda velja tudi omeniti, da lahko specializacije dodajamo kadarkoli, ni niti nujno da so v isti datoteki kot definicija šablone. S tem pridobimo na fleksibilnosti skupaj z elegantnostjo, ki jo makro ne ponuja. Dobimo tudi isto hitrost, saj se kratke šablone tako kot makri (pravzaprav bolj kot inline funkcije) vnesejo kar v kodo, od koder jih kličemo.

Šablonske funkcije lahko deklariramo tudi znotraj razredov:

   class File {
      fstream file;
      /*
         implementacija objekta, ki zna pisati v datoteko in je povsem nepomembna za ta primer
         */
      template<class T>
      void write(const T& object) {
         file << object;
      }
   };

Obnašajo se povsem enako kot ostale šablonske funkcije,vključno z možnostjo specializacije.

Zdaj pa za tiste skeptike, ki ste že slišali za šablone, poznate njihovo uporabnost a si jih ne upate uporabljati. Šablone so del standarda C++ in so v dobršni meri podprte na vseh popularnih C++ prevajalnikih. To pomeni, da so v osnovi podprte povsod, le nekateri starejši prevajalniki ne omogočajo vseh funkcionalnosti (npr: Visual Studio 6 ne mara delne specializacije - to bomo še opisali). Uporaba šablon torej ne bi smela biti več predmet bojazni o kompatibilnosti s prihodnjimi verzijami istega prevajalnika oz. z drugimi prevajalniki.

Toliko o tem. Zdaj pa naprej k bolj zanimivi temi - šablonskim razredom.


Šablonski razredi

Spet začnimo kar s primerom. Naredili bomo razred, ki bo predstavljal matematični vektor. Recimo da uporabljamo vektorje v praktično vsakem svojem programu, da uporabljamo različne tipe (včasih nam je dovolj vektor celih števil, drugič vektor realnih števil, včasih celo kompleksen vektor) in da večinoma uporabljamo 3D vektorje, včasih morda 2D spet drugič pa neke povsem tretje dimenzije. Kako bi torej napisali čim bolj kompaktno, ponovno uporabljivo, po možnosti neprepočasno in ne glede na uporabo lepo oblikovano kodo?

   template<class T = int, unsigned int N = 3>
   class Vector {
      protected:
         T elements[N];

         T defaultVal() const {
            return 0;
         }

      public:
         Vector() {
            for (int i = 0; i < N; ++i)
               elements[i] = defaultVal();
         }

         // kopiranje vsebine iz drugega vektorja

         template<class T2, unsigned int N2>
            void copy (const Vector<T2, N2>& v) {
               for (unsigned int i = 0; i < min(N, N2); ++i)
                  elements[i] = (T)v[i];
               for (int j = N2; j < N; ++j)
                  elements[j] = defaultVal();
            }

         // dostop do vsebine

         T& x() {
            return elements[0];
         }

         T& y() {
            return elements[1];
         }

         T& z() {
            return elements[2];
         }

         T& operator[] (unsigned int index) {
            return elements[index];
         }

         const T& operator[] (unsigned int index) const {
            return elements[index];
         }

         // se primer uporabne funkcije za matematični vektor

         T dotProduct(const Vector& v1, const Vector& v2) {
            T result = 0;
            for (int i = 0; i < N; ++i)
               result += v1[i] * v2[i];
            return result;
         }
   };

Preden bi bil predlagani razred dejansko uporaben, bi mu bilo dobro dodati še kake aritmetične funkcije kot so seštevanje, odštevanje, množenje itd. Je pa zaenkrat povsem dovolj, da prikaže uporabo šablone.


Kako se pa uporablja to čudo?

Povsem enostavno:

/ recimo da rabimo vektor števil tipa double

   Vector<double> mojVektor;
   mojVektor.y() = 12.0;
   mojVektor[1] = 12.0; // efekt je enak kot v zgornji vrstci

Šablonski razred - klasičen razred

Poskusimo primerjati klasičen objektni pristop in naše šablone.

Deklaracija razreda

template<class T, unsigned int N = 3>
class Vector

Pri šablonah imamo poleg vrstice z imenom razreda Vector še template<class T, int N>, ki nam pove:

  1. Naš razred ni navaden, temveč šablonski.
  2. Šablona ima parametra T in N.
  3. T je definiran kot razred oziroma class in je lahko osnovni, sestavljeni tip oz. objekt ter celo funkcijski tip.
  4. N je konstanta tipa integer z vrednostjo 3.

Kaj velja za privzete vrednosti parametrov v šablonah?

  • Vsem lahko določimo privzete vrednosti, toda le dokler so osnovnega tipa (parametru T bi lahko naprimer dodali privzeto vrednost tipa int).
  • Za njih veljajo enaka pravila, kot za privzete vrednosti navadnih funkcij.

Ko bomo sestavljali drobovje razreda, se moramo zavedati da je parameter T lahko poljubnega podatkovnega tipa, o katerem ne vemo ničesar. Čeprav predpostavljamo, da bomo pri našem vektorju imeli opravka le s številkami, uporabnika pri deklaraciji nismo omejili. Morda pa se bo kakšnemu zdolgočasenemu programerju zazdelo zabavno uporabiti std::string.

Funkcije

Pa se preselimo na morda manj jasne stvari. Vse zgornje funkcije so definirane znotraj razreda, oz. ne poznamo klasične ločnice med glavo (.h) ter vsebino (.cpp). Definicijo bi sicer v principu lahko ločili od deklaracije tako da bi spremenili:

   T& x() {
      return elements[0];
   }

v

   T& x();

in bi nekje v isti datoteki, za deklaracijo razreda dodali še

   template<class T, unsigned int N>
   T& Vector<T, N>::x() {
      return elements[0];
   }

vendar je to v primerih kratkih funkcij povsem nesmiselno ter manj pregledno.

Nadaljujmo s na prvi pogled morda čudno funkcijo

   template<class T2, int N2>
   void copy (const Vector<T2, N2>& v) {
      for (int i = 0; i < min(N, N2); ++i)
         elements[i] = (T)v.elements[i];
      for (int j = N2; j < N; ++j)
         elements[j] = default();
   }

Uporabili smo funkcijo min, ki je tudi šablonska. Za njeno uporabo moramo v program vključiti header <algorithm> in nekje vključiti using std::min;. Njeno delovanje upam, da je popolnoma jasno že iz imena. Zdaj imamo kar naenkrat še eno šablono znotraj prvotne šablone. To je seveda povsem sprejemljivo. Kot smo že omenili, imajo razredi lahko šablonske metode in pa šablonske razrede pišemo povsem enako kot navadne razrede. Torej šablonska metoda znotraj šablonskega razreda ni nič posebnega. Še več - v podanem primeru je neverjetno koristna. Omogoča namreč povsem neslutene možnosti kopiranja različnih tipov. Želite vaš vektor z elementi tipa double pretvoriti v float? Morda bi radi v dvodimenzionalen int vektor shranili prvi dve dimenziji vašega float vektorja? Ali pa vam manjka 333-dimenzionalna različica double vektorja? Nič lažjega:

   Vector<double> doubleVec;
   // nekaj kode, ki veselo spreminja doubleVec

   // ...


   // skopirajmo doubleVec v floatVec

   Vector<float> floatVec;
   floatVec.copy(doubleVec);
   // pa se floatVec v intVec

   Vector<int, 2> intVec;
   intVec.copy(floatVec);
   // in na koncu se v doubleVec v largeDoubleVec

   Vector<double, 333> largeDoubleVec;
   largeDoubleVec.copy(doubleVec);

Seveda obstaja še lepša možnost, da bi določili operator = in si s tem prihranili pisanje besede copy ter izboljšali preglednost programa (čeprav nekateri pravijo da oblaganje operatorjev zmanjšuje preglednost?! - bom malo zloben in rekel naj taki še kar naprej programirajo v Javi. Ampak pustimo operatorje za kdaj drugič in se vrnimo k prejšnjemu delu kode, kjer velja poudariti še eno zelo lepo lastnost šablon.

   for (unsigned int j = N2; j < N; ++j)
      elements[j] = default();

V tem stavku se j premika od N do N2. Ampak ti dve vrednosti sta vendar konstantni. Kaj če sta na primer enaki? Odgovor je preprost - prevajalnik bo sestavil kodo podobno tejle:

   for (unsigned int j = 3; j < 3; ++j)
      elements[j] = default();

C++ prevajalnik (pa naj bo to katerikoli - razen morda takega, ki ga sami spacate doma) je toliko pameten, da uvidi neumnost zgornjega. Napisana koda se namreč ne bo nikoli izvršila, ker 3 pač ni manjše od 3. Cela zanka je torej mrtva koda - koda ki se nikoli, ampak res nikoli ne izvede. In ker naš prevajalnik ne trpi neumnosti, bo zgornjo kodo enostavno odstranil. Isto bo storil v primeru, da je N manjši od N2. Podobno bo min(N, N2) spremenil v N2 (min je funkcija iz STL, ki si jo bomo na hitro ogledali kasneje). Pa vse skupaj raje ponovimo. Koda

   for (int i = 0; i < min(N, N2); ++i)
      elements[i] = (T)v.elements[i];
   for (int j = N2; j < N; ++j)
      elements[j] = default();

se bo v primeru, da je N2 manjši ali enak N spremenila v ekvivalentno tejle (druga for zanka je odveč in se odstrani, v prvi zanki se min(N, N2) spremeni v N2):

      for (int i = 0; i < N2; ++i)
      elements[i] = (T)v.elements[i];

oz. če je N2 na primer za 1 večji od N v tole (druga zanka se poenostavi v en stavek):

   for (int i = 0; i < N; ++i)
      elements[i] = (T)v.elements[i];
   elements[N2] = default();

Šablone torej ne prinašajo overheada, se pravi počasnejše kode. Ravno nasprotno. Ponavadi se reče, da nekaj run-time (za časa izvajanja) računanja prenesejo na compile-time (za časa prevajanja). To je kar se uporabnika programa tiče vedno dobrodošlo. Nekoliko manj dobrodošlo pa je morda za programerja, ki mora pri velikih projektih kar lepo čakati na prevajalnik.

Hura, osnove so jasne!

Zdaj se lahko preselimo k bolj zapletenemu delu s šablonami. Začnimo kar s specializacijami. En tak primer je bil že prikazan pri šablonskih funkcijah, vendar se da s šablonskimi razredi še veliko bolj pozabavati. Navadna specializacija bi izgledala takole:

   template<>
   class Vector<float, 2> {
      ...
      // popolna specializacija šablone Vector

      // vse funkcije je treba ponovno definirati, tokrat so vse

      //    šablonske spremenljivke določene

   };

Zakaj bi želeli to storiti v zgornjem primeru? Argument proti bi bil, da Vektor z dvema koordinatama tipa float ni nič posebnega in je zanj povsem primerna prvotna šablona. Argument za pa bi bil, da imamo časa na odmet, obvladamo zbirnik in MMX ukaze ter nujno rabimo hitrejši razred za take vektorje. V tem primeru ga lahko namreč primerno optimiziramo, kar s splošnim razredom (šablono) prej ni bilo mogoče. Razredov pa nam ni treba specializirati v celoti, lahko jih le delno (saj bi preklinjal C++ zaradi nedoslednosti, ker to ni mogoče s funkcijami, a ne bom, ker kdo pa sploh še uporablja običajne funkcije?).

   template<class T>

   class Vector<T, 0> {
      // Tu ne definiramo ničesar.


      // Prazna specializacija uporabniku pove, da vektor

      // dolžine 0 ni kaj prida in naj se mu raje izogiba.

      // Vsak preveč radoveden programer lahko še vedno ustvari objekt

      // tega tipa, le da si z njim ne bo kaj dosti pomagal, saj nima

      // definirane nobene funkcije, ki bi jo lahko uporabil.

   };

   int main() {
      Vector<int, 0> vec; // naša specializacija bo prijela ob tej zahtevi

      vec.x() = 0;        // ERROR! to zdaj ni več možno

   }

Seveda bi bila lahko specializacija tudi bolj smiselna a važen je nauk - specializiramo lahko le tiste šablonske parametre, ki si jih želimo (v našem primeru le drugega).

Kako torej deluje delna specializacija?

Specializacijo definiramo tako kot osnovno šablono, le da ustrezno zmanjšamo število šablonskih parametrov, takoj za ime razreda pa dodamo trikotne oklepaje in znotraj njih določimo parametre istoimenske osnovne šablone. Tu obstaja še ena zanka. Namesto da šablonskim parametrom trdno pribijemo neko vrednost ali tip, jih lahko le malce popravimo.

   template<class T, unsigned int N>
   class Vector<T*, N> {
      ...
      // tale implementacija je pa za tiste, ki ne morete

      // brez kazalcev

   }

Čeprav je iz same sintakse slabo razvidno oziroma popolnoma neintuitivno, smo z zgornjo kodo zagotovili drugačno obnašanje v primeru koordinat tipa kazalec na nekaj. Ko torej napišemo

Vector<char*, 10> cudenVektor;

bo prevajalnik pogledal šablono Vector in vse njene specializacije. Našel bo dve varianti, ki ustrezata temu zapisu in sicer osnovno Vector<T*, N> in Vector<T*, N>. Izbral bo drugo, zato ker se ta bolj natančno ujema z definicijo oziroma je manj splošna. cudenVektor bi se pravilno prevedel tudi če ne bi napisali zgornje specializacije. Ker pa ponavadi želimo drugačno obnašanje za kazalce, je to zelo priročen način, da ga tudi zagotovimo.

V primeru vektorja uporaba kazalcev na žalost ni najbolj smiselna, pride pa ta možnost bolj v poštev pri kontejnerjih in podobnih razredih. Na ta način lahko specializiramo T*, T&, const T in vse možne kombinacije prejšnjih treh.

Previdnost pri specializacijah

Tisti, ki še niste zaspali pri branju, ste verjetno opazili možno majhno past pri specializaciji. Kaj če uporabimo zgornjo šablono Vector in dodamo naslednje specializacije:

template<int N>

class Vector<double, N> {
   cout << "Vector double N";
};

template<class T>
class Vector<T, 10> {
   cout << "Vector T 10";
};

int main() {
   Vector<double, 10> vect;
}

Prevajalnik bi pri zgoraj napisani uporabi šablone lahko uporabil prvo ali drugo specializacijo. Kaj se bo torej izpisalo v konzoli v zgornjem primeru? "Vector double N" ali "Vector T 10"? Odgovor je seveda nič od tega. Zgornji specialzaciji sta si enakovredni (nobena ni bolj ali manj splošna), zato prevajalnik ne ve katero bi bilo bolje uporabiti in izpiše lično sporočilo o napaki. To pomeni, da se iz sporočila takoj razbere kje in v čem je napaka, kar sicer ponavadi ne drži pri napakah zaradi šablon. A pustimo napake, če se lotite programiranja šablon jih boste lahko sami videli še malo morje. Kaj lahko storimo v takem primeru, se verjetno sprašujete. Rešitev je kot vedno povsem enostavna, definiramo še spodnjo specializacijo:

template<>
class Vector<double, 10> {
   cout << "Vector double 10";
};

S tem ustvarimo specializacijo, ki je manj splošna in ima zato prioriteto pred ostalima dvema specializacijama, ki bi sicer tudi ustrezali.

Zdaj pa še nekaj za vse, ki se nam ne da prepisovati celega razreda.

Če želimo specializirati le delovanje znotraj ene ali dveh metod potem ni smiselno, da prepišemo cel razred (beri: uporabimo copy-paste), spremenimo pa mu le tisti dve metodi. Temu načinu se reče eksplicitna specializacija in jo napišemo za vsako metodo posebej.

   template<unsigned int N>

      char Vector<char, N>::defaultVal() const {
      return '0';
   }

Zdaj smo prelisičili prevajalnik, da nam bo v primeru uporabe vektorja s koordinatami tipa char za privzete vrednosti vzel znak '0' in ne vrednosti 0. Zvito ni kaj in pa zelo uporabno v primeru zelo dolgih šablonskih razredov.

Končajmo ta del s povdarkom, da bodo šablone delovale tudi brez vseh specializacij, z njimi bodo delovale le bolj prilagojeno na kake posebne razmere, in se posvetimo nečemu, kar v teoriji ni nič posebnega, v praksi pa često povzroča težave.


Struktura datotek pri uporabi šablon

Že od programskega jezika C dalje kodo ponavadi razdelimo na prototipe (deklaracije) in njihovo implementacijo (definicijo). Podobno v C++ ločimo deklaracijo razreda od njegove definicije. Deklaracijo shranimo v header (ponavadi s končnico .h ali .hpp), definicijo pa v glavno datoteko (.c, .cpp, .c++, .cc). Ločitev je smiselna predvsem iz dveh razlogov - skrivanje kode in hitrosti prevajanja. Pustimo skrivanje kode in se posvetimo hitrosti prevajanja. Ta se poveča zato, ker .cpp datotek ni potrebno prevajati, če se nič ne spremenijo in imamo njihovo zadnjo prevedeno verzijo (ponavadi .o datoteka ali pa del knjižnice). S pametnim ločevanjem deklaracij in definicij, predvsem pa različnih definicij v svoje datoteke si lahko bistveno skrajšamo povprečni čas prevajanja kode (predvsem pri velikih projektih).


Spremembe strukture pri šablonah

Šablone se ne prevajajo vnaprej (da bi dobili .o datoteko), temveč sproti, vsakič ko prevajalnik naleti na novo obliko (z drugačnimi parametri, kot pri prej že prevedenih). Primer:

   int i = max(0, j); // opazi se šablonska funkcija max, prevede se jo za parameter int

   float f = max(100.0f, (float)j); // opazi se šablonska funkcija max, prevede se jo za parameter float

   char ch = max('a', 'A'); // opazi se šablonska funkcija max, prevede se jo za parameter char

   // ...

   // nekaj kode vmes

   // ...

   int i2 = max(i, -i); // opazi se šablonska funkcija max, uporabi se prejšnji prevod za parameter int

Če hoče prevajalnik zgornjo kodo uspešno prevesti, mora imeti dostop do definicije uporabljene šablone. Lahko je to kar v isti datoteki ali pa vključena preko stavka #include (seveda ni treba direktno #include<algorithm>, lahko je tudi #include "mojheader.h" in v datoteki mojheader.h #include <algorithm>). Prevajalnik torej vedno zahteva kode šablone. Zato je v navadi, da se colotna koda šablon vedno piše v header datoteko. Lahko bi jo napisali tudi v .cpp datoteko in potem dodali #include "sablona.cpp", kar pa še vedno ne bi bilo smiselno niti s stališča skrivanja kode, niti s stališča hitrosti prevajanja.


STL

STL je kratica za Standard Template Library - Knjižnica standardnih šablon. Je del standarda jezika C++ in jo zato dobimo skupaj s prevajalnikom. Ponuja paleto funkcij in razredov, s poznavanjem katerih si lahko zelo poenostavimo življenje, oziroma vsaj programiranje. Okvirno se STL deli na kontejnerje (razrede za hranjenje zbirk podatkov), iteratorje (objekti, ki vsebujejo neke vrste pozicijo znotraj kontejnerjev) in algoritme. Organiziran je kot množica header datotek (vse so brez končnice!) - vector, deque, list, string, algorithm, ... Vsi razredi in funkcije so shranjeni v imenskem prostoru (namespace) std, zato jih moramo naslavljati temu primerno:

   std::string("ok");
   // ali pa

   using std::string    // od sedaj naprej lahko pisemo string brez predpone std::

   string("spet ok");
   // ali pa

   using namespace std  // sedaj lahko pisemo vse elemente STL brez predpone std::

   string("tudi ok");

V nadaljevanju tutoriala se predvideva, da je uporabljen using namespace std.


Pametni kazalci

Zelo uporaben a kar malo zanemarjen je pametni kazalec (smart poiner) auto_ptr. Čeprav to ni najbolj uporabna vrsta pametnega kazalca pa je zagotovo ena najenostavnejših. Uporaba je skoraj identična uporabi navadnega kazalca, definicijo pa najdemo v headerju <memory>:

   #include <memory>
   using namespace std;

   double* data = new double;
   *double = 1.0;
   delete data;         // za sabo moramo počistiti


   auto_ptr<double> aData(new double);  // podobno kot zgoraj

   *aData = 1.0;        // kot bi uporabljali dobro znani kazalec

               // za nami bo počistil kar auto_ptr sam

Detajli pametnih kazalcev, primerni le za tiste z najtrdnejišmi živci

Vsebina ni povezana s šablonami, zato lahko tisti, ki vas to ne zanima, preskočite na "kontejnerje".

Pa povejmo kaj sploh je pametni kazalec in v čem se razlikuje od ostalih (neumnih) kazalcev. Problem kazalcev je predvsem v skrbi za njih. Ko začnemo dinamično alocirane objekte premetavati sem ter tja, kazalce pošiljati skozi funkcije, jih premikati iz enega objekta na drugega itd, se hitro srečamo iz oči v oči s problemom lastništva objekta. Lastništvo se sicer sliši dokaj hudo, gre več ali manj za to, kdo bo objekt uničil, se pravi kje se bo klical operator delete. To bi še šlo, a kaj ko ima vedno prste vmes človek. Človek pa ima veliko težnjo k pozabljivosti in to ima zelo slabe efekte na klicanje operatorja delete. Če na tem apliciramo osnove logike ugotovimo, da imajo dinamično alocirani objekti težnjo k ali ostajanju v spominu tudi po koncu njihovega uporabnega življenja ali pa biti sproščeni več kot enkrat. Vsak objekt, katerega kazalec se pozabi, ne da bi se sprostilo alociran pomnilnik lahko imenujemo memory leak (ne vem če si želim tole sploh prevajati). Pomnilnik ki ga zaseda tak objekt se lahko sprosti le v dveh primerih:
1.    program se uspešno konča in sprosti ves s svoje strani zaseden pomnilnik,
2.    program po domače crash-a in sprosti ves s svoje strani zaseden pomnilnik.

Večina nedeljskih programerjev računa na prvo možnost in s tem ni nič narobe. Če je ves smisel programa izpisati ASCII tabelo ali kaj podobnega, lahko to stori tudi s memory leakom velikosti nekaj kilobajtov. Saj bo to trajalo tako ali tako le nekaj milisekund (tisti del programa, kjer je kazalec res pozabljen). Če pa se to zgodi pri resnem programu, ki mora delovati dlje časa in če se take napake vrstijo, je veliko bolj pametno poseči po drugi zanki.

Druga težava, ki je prav nasprotna od pozabljanja sproščanja pomnilnika, je sproščanje istega kosa pomnilnika dvakrat. Teoretično bi ga sicer bilo možno sprostiti tudi večkrat a kaj, ko program nesrečni konec stori že ob prvem poskusu sproščanja nezasedenega (ali že sproščenega) pomnilnika.

   // zelo poenostavljen primer dvakratnega sproščanja istega dela pomnilnika

   double* data = new double;
   *data = 10.0;
   delete data;   // ok, počistili smo za sabo

   delete data;   // preveč čistoče škodi, zato ERROR

In kako auto_ptr skrbi za naš spomin?

Delovanje auto_ptr je Čisto preprosto - v destruktorju kliče delete. Pomnilnika, ki si ga lasti auto_ptr torej ne sproščamo sami ampak to delo prepustimo destruktorju. Na istem kazalcu torej ne moremo 2x klicati operatorja delete. Kaj pa če naredimo kopijo pametnega kazalca? Potem se bosta klicala dva destruktorja in spet se bo 2x klical delete?

   auto_ptr<doubble>
   data = new double;   // varno, ker nam ni treba klicati delete

   auto_ptr<doubble>

   copy = data;      // ali se bo zdaj delete klical dvakrat

            // (v vsakem destruktorju enkrat)?

Nikakor ne. Tu pride v veljavo glavni razlog, zakaj se auto_ptr tako malo uporablja. Kopiranje namreč ni možno. Namesto kopiranja se prenese lastništvo (v zgornjem primeru je ob koncu kode copy lastnik dinamično alocirane spremenljivke, data pa ni lastnik ničesar). In v destruktorju se sprosti le pomnilnik, katerega lastnik je pametni kazalec. Prvotni lastnik niti ne kaže več na prej alocirano spremenljivko ampak postane ekvivalenten NULL. auto_ptr torej lahko uporabljamo kadar želimo varnost, ne potrebujemo pa zmožnosti kopiranja. Ugotovimo lahko, da z malo discipline pri programiranju postane auto_ptr kar uporaben in poleg izboljšane varnosti in čitljivosti prispeva tudi k lepšemu programerskemu slogu. Še ena malenkost, ki se je ne opazi na prvi pogled, auto_ptr ne more hraniti dinamično alocirane tabele. Razlog za to je čisto preprost. V destruktorju auto_ptr se kliče delete. Za sproščanje dinamično alocirane tabele pa moramo klicati delete[]. Razlika je majhna a zelo pomembna.

   void func() {
      auto_ptr<double> data(new double[100]);
   }
   // v najboljšem primeru bo koda povzročila sesutje programa

"Kontejnerji"

Prav gotovo boste sedaj rekli: "Toda kaj nam bodo pametni kazalci, ki ne znajo alocirati tabel, saj večinoma dinamično alociramo prav tabele!" Za varnost pred pozabljivostjo v primeru dinamičnih tabel programerji potrebujemo nekaj drugega. Razrede, ki hranijo zbirke, v STL imenujejo kontejnerji.

Vector

Absolutno najbolj uporaben izmed njih je vector, njegovo uporabo omogočimo z vključenjem headerja <vector>. Predstavljamo si ga lahko kot varno dinamično tabelo (array).

   int staticArray[100];         // statična tabela

   int* dynamicArray = new int[100];   // dinamična tabela

   vector<int> theArray(100);         // doh!

Statična tabela se ustvari takoj ob deklaraciji. Je zelo omejene dolžine (omejena je z velikostjo sklada, ki je na voljo programu), a zato neprimerljivo hitrejša od ostalih tabel kar se ustvarjanja in uničevanja tiče, ne initializira elementov in je ni potrebno (beri: ni dovoljeno) eksplicitno brisati. Njena dolžina je fiksna in mora biti konstanta (int staticArray[x]; kjer je x spremenljivka ni dovoljeno - čeprav nekateri prevajalniki mislijo da so pametnejši od ANSI standarda). Dinamična tabela se ustvari šele ko se kliče operator new[size], vse elemente ob kreaciji tudi initializira, potrebno je eksplicitno klicanje operatorja delete[], ko se jo želi uničiti, njena velikost pa je še vedno fiksna, ni pa potrebno da je konstanta (new int[x]; je povsem v redu). vector je ovojnica (wrapper) dinamične tabele, in zato od nje podeduje vse lastnosti, doda pa ji možnost spreminjanja velikosti, možnost preverjanja indeksa ob naslavlanju posameznega elementa, funkcije za kopiranje, brisanje in podobne bonbončke. Eden izmed najbolj okusnih bonbončkov je kar samodejna sprostitev pomnilnika ob končanju njenega življenja. Uporaba je izjemno enostavna, (vsaj v osnovi) skoraj identična uporabi statične tabele.

   vector<int> vec(1000);  // naredimo novo tabelo intov z začetno velikostjo 1000

   vec[10] = 1;         // običajno hitro dostopanje do elementa (brez preverjanj)

   vec.at(20) = 2;         // dostopanje s preverjanjem indexa - če je indeks prevelik

               // pride do prekinitve (exception)

   vec.resize(2000);    // povečanje tabele na 2000 elementov, prvih 1000 elementov

               // ohrani svoje vrednosti, dodani imajo privzeto vrednosti;

               // zavedati se je treba postopka povečevanja tabele -

               // alocira se nov, večji kos pomnilnika, vanj se skopira

               // staro tabelo, staro tabelo se uniči - pri velikih

               // tabelah in kompleksnih elementih je postopek zelo počasen

   vec.reserve(10000);     // rezervira za 10000 elementov spomina, tako da bodo prihodnja

               // povečevanja tabele (seveda le do 10000 elementov) zelo hitra

               // (brez kopiranja)

   vec.push_back(3);    // tabelo poveča za 1 in v zadnji element zapiše vrednost 3

   vec.erase(vec.begin() + 10, vec.end() - 10); // brisanje vseh elementov razen prvih in zadnjih 10;

               // (funkciji vec.begin() in vec.end() vrneta t.i. iteratorja na začetek

               // oz. konec tabele - več o iteratorjih kasneje)

               // velikost tabele je sedaj le 20 elementov, rezervirana velikost pa je se vedno 10000

String

Zanimiv je vectorjev bližnji sorodnik string. Obnaša se zelo podobno, specializiran pa je za za tip char. No, pravzaprav string ni pravi kontejner, niti prava šablona. Na prvi pogled je videti kot povsem običajen razred. A ni. V resnici je to typedef basic_string<char> string. Šablona z definiranim parametrom, ki se pretvarja da je razred, torej. No, string tu omenjamo le zato, ker se za znakovne nize ne uporablja vector<char> ampak string. Ponuja podobno fleksibilnost z dodanimi rešitvami za izpisovanje ter manipulacijo znakovnih nizov. Možna je tudi uporaba wstring, ki je unicode različica stringa (typedef basic_string<wchar_t> wstring;). Njihova uporaba je nadvse enostavna (hmm, besedna zveza enostavna uporaba je pa pogosta, ko govorimo o STL):

   string mojString1("en način zapisa");
   string mojString2 = "drug način zapisa";
   char[] mojCharString = "način kopiranja";
   // kopiranje je nadvse enostavno

   mojString1 = mojCharString;
   // prav tako primerjanje

   if (mojString1 == mojString2) {} // sta enaka?

   // ali pa dodajanje

   mojString1 = mojString2 + " dodatek";

Map

Zelo zanimiv in malo nenavaden (vsaj za tiste, ki ste navajeni na C) kontejner je map. Zanimiv je predvsem zato, ker je asociativen. Nenavadnost pa tiči v overloadanju operatorja [].

Asociativen kontejner - kaj je že to? Za uporabnika to predvsem pomeni, da njegovi elementi niso indeksirani tako kot pri ostali kontejnerji - s številkami od 0 do velikost-1 ampak s čimerkoli si uporabnik zaželi. Pravzaprav bi mu lahko po kakšni drugi terminologiji rekli kar slovar. To že zveni bolj razumljivo? No, da bo še bolj si kar oglejmo preprost primer uporabe.

string polepsajBesedilo(const string& besedilo) {
   \\ ustvarimo kontejner nizov znakov, asociiranih z drugimi nizi znakov
   map<string, string> slovar;
   \\ napolnimo slovar 'neprimernih' besed in njihovih 'primernih' sopomenk
   slovar["shit"] = "s***";
   slovar["idiot"] = "mentally challenged person";
   ...

   \\ rezultat lepšanja
   string polepsanoBesedilo;

   \\ pripravimo si vse za branje besedila
   strstream streamBesed(besedilo);
   while (!streamBesed.eof()) {
      string beseda;
      // funkcija, ki bere besedo po besedo (je sicer malo naivna in misli da

      // so besede lahko ločene le s presledki)

      getline(streamBesed, beseda, " ");
      // če je beseda v slovarju, potem jo v polepšanem besedilu zamenjamo s

      // sinonimom iz slovarja, drugače vzamemo kar originalno besedo

      if (slovar.find(beseda) == slovar.end()) {
         // če je ni v slovarju, bo funkcija find vrnila kar iterator na konec

         // slovarja, zato bo pogoj v if izpolnjen

         polepsanoBesedilo.append(" ");
         polepsanoBesedilo.append(beseda);
      } else {
         polepsanoBesedilo.append(" ");
         polepsanoBesedilo.append(slovar[beseda]);
      }

      return polepsanoBesedilo;
   }
}

Tako! Zdaj lahko našo mladino obvarujemo pred grdimi besedami, ki bi jo sicer lahko popolnoma pokvarile. Brez uporabe šablone map bi bilo to mnogo težje. Pa si poglejmo še kako pravzaprav ta šablona deluje. Za začetek dve deklaraciji:

template<class Key, class T, class Pred>
   class map

template<class T, class U>
   struct pair

Šablona map ne hrani običajnih elementov ampak pare (pair). Par je, kot že njegovo ime pove, skupina dveh objektov. Vsak objekt je lahko kateregakoli tipa. Znotraj map pravimo prvemu objektu tudi ključ in drugemu vrednost. Verjetno se že sprašujete zakaj je tu govora o nekih parih, če pa ni prav nobenega v zgornji kodi. Res je, pari znotraj map so lepo zakriti. A s stavkom slovar["shit"] = "s***"; pravzaprav dodamo v slovar nov par, katerega ključ je "shit" in vrednost "s***". Tip ključa določimo s prvim šablonskim parametrom (Key), tip vrednosti pa z drugim (T).

Kontejner je zasnovan tako, da omogoča hitro iskanje vrednosti po njihovem ključu. Zato hrani elemente sortirane po ključu. Sama struktura, v kateri so elementi shranjeni ni določena s standardom. Obvezno pa mora omogočati hitro dodajanje in iskanje elementov. Pogosto je implementirana z rdeče-črnim drevesom. Ampak naj nas take podrobnosti ne zanesejo predaleč. Nadaljujmo z dvema zahtevama, ki jih prinaša urejenost elementov, za tip, ki bi si želel nastopati kot ključ. Prva je, da morajo biti vse možne vrednosti tega tipa urejene. Zahtevo lahko izpolnimo na dva načina - z definicijo operatorja < ali pa z definicijo posebne funkcije (šablonski parameter Pred), ki deluje podobno (vzame dva parametra in vrne true, če je prvi "manjši" od drugega). Pozor! Recimo da imamo definiran razred, podan operator < in tri objekte tega razreda - a, b in c. Če velja a < b, b < c in c < a, potem ta razred ni primeren za ključ. Prevajalnik se sicer ne bo pritožil (in jaz sem vedno mislil, da C++ razume matematiko bolje od mene), delovanje takega kontejnerja pa zna biti precej nepredvidljivo.

Druga zahteva je, da lahko objekte primerjamo z operatorjem == in dobimo smiseln rezultat. Ta pogoj ne drži že za preprost tip char*. Operator == sicer na tem tipu deluje, a ne ravno smiselno, saj primerja kazalce in ne njihove vsebine (kar bi si mi ponavadi želeli). Primeri razredov, ki izpolnjujejo obe zahtevi so int, double, char, string.

Omenimo še nekaj posebnosti kontejnerja map. Sintaksa slovar["uh"] = "oh" je sicer zelo priročna a skriva nekaj pasti. Po slovarju ne moremo iskati na tak način kot v drugih kontejnerjih. Recimo string uh = slovar["uh"] ne deluje kot bi si mislili. Četudi izgleda, kot da iz slovarja v tem primeru le beremo, nas bo map presenetil in dodal ključ "uh" z privzeto vrednostjo "", če v slovarju ne bo našel še nobenega zapisa "uh". Zato moramo vedno prej preveriti, če ključ je v slovarju, šele potem ga lahko poskušamo prebrati (preverimo ga lahko z metodo map::find("uh")).

Še ena malenkost, ki jo novinci ponavadi spregledajo je ta, da iskanje po vrednosti ni mogoče (no, mogoče je tako kot pri vseh ostalih kontejnerjih, a je izjemno počasno). Če želimo zbirko vrednosti (ki niso asociirane z ničemer) in želimo te vrednosti hitro dodajati in iskati, lahko namesto map uporabiti set. set je množica elementov nad katerimi obstajajo hitre operacije dodajanja, brisanja in iskanja.

V globlje detajle vector-ja, string-a, map-a ali ostalih kontejnerjev se ne bomo spuščali. Na internetu je kar nekaj tutorialov, popolnih referenc in celih knjig na temo STLja, kjer lahko najdete o njih prav vse.


Kaj je še vrednega omembe v STL?

Omenimo le nekaj najbolj zanimivih algoritmov (header <algoritm>):

  • min, max - funkcije ki vračata minimum in maximum izmed dveh podanih vrednosti
  • min_element, max_element - poiščetaminimalen oziroma maksimalen element v podani zbirki
  • sort - sortira podano zbirko (ali njen del) po velikosti
  • find - poišče element v zbirki
  • count - prešteje identične elemente vzbirki
  • copy - kopira elemente iz ene zbirke v drugo (zbirke niso nujno istega tipa)

Nekaj zanimivih kontejnerjev (poleg vectorja), ki se ponavadi nahajajo v istoimenskih headerjih:

  • list - dvojno povezana lista (če še niste slišali zanjo priporočam, da pogooglate za "doubly linked list")
  • map, multimap - asociativni kontejner, vsak elemente v njem je asociiran z neko vrednostjo (v navadnem kontejnerju je vsak element asociiran le s svojim indeksom pa se to le do takrat, ko v zbirko pred njega vrinemo nek drug element), do elementov dostopamo prek teh vrednosti (in ne prek indeksov); multimap je verzija map, ki dovoljuje ponavljanje elementov
  • deque - zelo podoben vektorju, le da zmore zelo hitro dodajanje elementov na začetek in konec
  • stack - sklad
  • set, multiset - množica elementov, za vsak element lahko le rečemo ali je v množici ali pa ga ni; multiset je množica, kjer se elementi lahko ponavljajo.

Še primer uporabe iteratorjev (ti se navadno nanašajo na kontejnerje, zato ni skupine 'zanimivih' iteratorjev).

   vector<int> data;
      sort(data.begin(), data.end());

begin je metoda (funkcija znotraj razreda) kontejnerjev (večina kontejnerjev ima zelo podobne metode), ki vrne iterator na prvi element. end je metoda ki vrne iterator na prvi neveljaven element (tega si lahko predstavljamo kot en element za zadnjim). Za lažje razumevanje podajmo primer iteratorjev za navadno dinamično tabelo:

   int* data = new int [100];
   typedef int* Iterator;       // iterator je v tem primeru kar kazalec na int

   Iterator beginIt = data;     // kazalec na prvi element

   Iterator endIt = data + 100; // kazalec na prvi element po koncu tabele

Zanimivo je, da standardni algoritmi, ki imajo za vhodne parametre iteratorje, povsem brez težav prežvečijo tudi take iteratorje.

   //nadaljevanje prejšnje kode

   sort(data, data+100);        // sortirajmo navadno tabelo

Funkcija sort sprejme dva parametra - iteratorja na začetek in konec zbirke, ki jo sortiramo. Konec je v STL-u vedno mišljen kot prvi element za veljavnim področjem zbirke. Podobno delujejo tudi ostale funkcije na zbirkah.


Kaj še ponujajo šablone

Poleg očitne parametrizacije tipov in s tem generalizacija oz. posploševanje kode so šablone zanimive še iz drugih vidikov. Pri tem so večinoma v obliki šablonskih razredov, s katerimi se da delati prav neverjetne stvari.


Politike

Srhljivo ime odličen način uorabe šablon. Generično programiranje lahko razširimo na uporabo politik (policies). Z njimi ne spreminjamo direktno tipov, s katerimi deluje naš razred, temveč bolj njegovo delovanje. Pri tem lahko s pridom izrabljamo prevajalnikove optimizacije. Prevajalniki so namreč naša delovna sila, zato jo moramo dobro izrabiti (od nekaterih bi za njihovo ceno pričakovali tudi, da nam skuhajo kavo in odnesejo smeti - a ker to ne gre, bo moralo zadostovati že prevajanje šablon). Zelo enostaven primer:

   struct StrictCheckPolicy {
      enum {
         doCheck = true,
         doThrow = true
      };
   }

   struct DoCheckPolicy {
      enum {
         doCheck = true,
         doThrow = false
      };
   }

   struct DoNotCheckPolicy {
      enum {
         doCheck = false,
         doThrow = false
      };
   }

   template <class CheckPolicy = DoCheckPolicy>
   class Dummy {
      vector<double> data;
      public:
         double access(int index) {
            // striktno preverjanje - exception v primeru napake

            if (CheckPolicy::doThrow)
               if ((index < 0) || (index >= data.size))
                  throw "Attention, invalid index!";
            // milo preverjanje - vrne 0, če pride do napake

            if (CheckPolicy::doCheck)
               if ((index < 0) || (index >= data.size))
                  return 0;
            // nič več preverjanja

            return data[index];
         }
   }

Razredu Dummy lahko s šablonskim parametrom določimo politiko preverjanja veljavnosti indeksov. Morda se to ne zdi smiselno, če lahko isto storimo z običajno spremenljivko. A je tudi v tem enostavnem primeru lahko še kako smiselno. Prevajalnik namreč zgornje if stavke lahko razreši že med prevajanjem in zato tudi odstrani vse odvečne. Če vzamemo DoNotCheckPolicy bomo zato dobili zelo hitro kodo, ki bo znotraj funkcije vsebovala le return data[index]. Verziji Dummy<> (vzame se kar privzet parameter, to je DoCheckPolicy) in Dummy<DoStrictCheck> lahko uporabljamo med razvojem programa, ko želimo imeti varno kodo ali pa vedeti za vse napake, hitrost pa še ni najpomembnejša. Ko program dodelamo toliko, da smo prepričani v veljavnost podanih indeksov deklaracijo Dummyjev enostavno spremenimo v Dummy<DoNotCheckPolicy> (seveda to storimo s kakšnim typedef-om, tako da ni treba prebrskati vse kode in ročno popravljati deklaracij). S tem pridobimo na hitrosti, ki jo morda potrebujemo.


Politika v STL

Še en primer uporabe politike je že znani razred vector. Čeprav smo ga do sedaj uporabljali le z enim parametrom, s katerim smo povedali, kakšne tipe naj shranjuje, obstaja v definiciji še en šablonski parameter (enako velja tudi za definicijo map). Iz prejšnjih definicij je bil izpuščen iz preventivnih razlogov - da bralčevi možgani ob naporu ne bi storili BUM!:

   template <class T, class Allocator = allocator<T>>
   class vector;

Allocator (recimo mu kar po naše - alokator) določa politiko upravljanja s pomnilnikom za vector. Vsebuje funkcije allocate in deallocate in podobne, preko katerih vector ustvari, popravi velikost in sprosti tabelo. Privzet alokator se imenuje allocator in je del STLja. S pomnilnikom upravlja kar preko new in delete operatorjev. Če želimo imeti upravljanje s pomnilnikom bolj pod kontrolo ga lahko zamenjamo s kako svojo implementacijo. Razlog za implementacijo s šablono in ne na primer z dedovanjem je zopet hitrost delovanja, enostavnost uporabe in dodatna fleksibilnost. Druga možnost je, da privzeti alokator malo razširimo. Recimo, da nas zanima kako deluje kontejner vector.

#include <vector>

using namespace std;

// pozor, alokator je tudi šablona

template<class T>
class MyAllocator : public allocator<T> {
   public:
   // izpisujmo kdaj se zgodi allocate ali deallocate

   pointer allocate(size_type n, const T* p = 0) {
      cout << "alociram " << n << " elementov" << endl;
      // kličemo podedovano funkcijo

      return allocator<T>::allocate(n, p);
   }

   void deallocate(T* p, size_type n) {
      cout << "dealociram " << n << " elementov" << endl;
      // kličemo podedovano funkcijo

      allocator<T>::deallocate(p, n);
   }

   // obvezno moramo definirati rebind<T>::other, da bo lahko kontejner

   // po potrebi alociral pomnilnik za kaj drugega kot za podan tip -

   // na primer za vozlišča v drevesu, če je kontejner organiziran kot

   // drevo

   template<class U>

   struct rebind {
      typedef MyAllocator<U> other;
   };
};

int main() {
   // uporabimo novi alokator kar v vektorju ...

   vector<int, MyAllocator<int> > vec;
   // ... in ga malo potestirajmo

   for (int i = 0; i < 10000; ++i)
      vec.push_back(i);
}

Iz izpisa tega programa lahko vidimo, kako deluje dinamična tabela, ki je v STL implementirana kot vector. Napisana koda že spet ni kaj prida uporabna, razen morda za razhroščevanje, a zadovoljivo prikazuje kako lahko vectorju vsilimo našo politiko. Podobno lahko storimo z ostalimi kontejnerji. Vsi sprejmejo kot šablonski parameter politiko alokacije spomina. Vsiljevanje prirejenih upravljalcev s spominom je najprimernejše tam, kjer poznamo vzorec, po katerem se ravnajo alokacije in dealokacije. Tako lahko na primer vnaprej alociramo dovolj spomina za dva slovarja (map), za katera vemo, da bosta pogosto spreminjala velikost, vendar bo njuna skupna velikost ves čas konstantna ali vsaj ne bo presegla neke meje. Nato s prirejenim alokatorjem le še skrbimo za pametno dodeljevanje in odvzemanje elementov v ta dva slovarja. Pohitritev na ta način je lahko velika, prav tako prihranki na zasedenem prostoru. Vedeti moramo namreč, da je privzeti upravljatelj s spominom nastavljen tako, da najbolje deluje kadar alociramo velike kose pomnilnika naenkrat. Zato je alociranje veliko majhnih kosov zamudno in tudi potratno. Prirejen upravljalnik s spominom torej lahko pride zelo prav - pod pogojem da ga znamo napisati bolje od privzetega seveda.


Zadosti že s to politiko!

Naredimo kratek povzetek. Uporaba šablon nam lahko zmanjša število vrstic kode - to je lahko slabo, če smo plačani po vrsticah. Poveča nam čas prevajanja programa takrat, ko spreminjamo šablonske parametre ali šablone same - dobro, če smo plačani po urah. Z njihovo uporabo lahko naredimo opazno razliko z minimalnimi posegi v kodo - slabo glede plačila, ne glede na to, za kaj nas plačujejo, a hkrati dobro, ker dokažemo svoje gurujstvo - še posebej kadar izboljšujemo tujo kodo. No, zares slabe strani so le manjša preglednost kode (za neizurjene oči), nepreglednost napak (čeprav se prevajalniki počasi izboljšujejo na tem področju) in pa neprebavljanje take kode na starejših prevajalnikih. S starejšimi je tu označen tudi določen prevajalnik (naj ostane neimenovan) iz leta 2002, ampak pustimo podrobnosti. Zelo dobra stran pa je vsekakor perspektivna prihodnost. Bodisi kot pisanje svojih bodisi uporaba že napisanih šablon, kot jih najdemo v STL in nekaj popularnih knjižnicah (Loki, Boost). Teh zadnjih bo v prihodnosti čedalje več.

Šablone poleg omenjenih politik in generalizacije podatkovnih tipov omogočajo tudi statični polimorfizem, metaprograme in še kaj. Pa naj si bodi dovolj o njih. Materiala o naprednih metodah uporabe šablon je še zelo veliko, zato je bolje zaključiti tutorial, še preden zaidemo v previsoko težavnostno stopnjo.

Če se znajdete v C++, šablon pa še ne poznate, vam svetujem, da jih preizkusite. Ne bo vam žal.

DirectX 9.0 - Osnove programiranja

DirectX 9.0 - Osnove programiranja

V prejšnjih dveh člankih smo si ogledali vse novosti, ki jih je Microsoft uvedel v DirectX. Ker verjamem, da mnogi med vami niso ravno programerji, vas pa vseeno zanima, kako izgleda življenje na drugi strani, sem se odločil, da vam to vsaj malo približam. V tem članku bo ...

Preberi cel članek »

Novosti DirectXa 9 - 1. del

Novosti DirectXa 9 - 1. del

No pa smo vendarle dočakali novo, dolgo pričakovano, različico knjižnice DirectX, ki jo je zlobni Microsoft, bolj ali manj skrbno skrival pred radovednimi očmi javnosti. V približno enaki tajnosti sta nastala tudi ta dva članka, ki vam bosta, vsaj upam tako, uspela ...

Preberi cel članek »

Programski jezik C++

Programski jezik C++

Kot vas večina verjetno ve, današnji računalniki razumejo le bitni jezik, ki mu pravimo tudi strojna koda (machine code). Če torej hočemo, da naš računalnik izvede neko nalogo ali opravilo, mu moramo to podati v obliki, ki jo razume, torej v enicah in ničlah. Ker ...

Preberi cel članek »

Varnost v PHPju

Varnost v PHPju

Dandanes ogromno ljudi uporablja PHP z namenom, da hitro sestavijo svoje dimanično generirane spletne strani. Ob tem seveda nihče ne pomisli na varnost oziroma na pisanje &quot;varne&quot; kode - brez oziroma z malo možnosti vdora in zlorabe strežnika, na katerem so skripte postavljene. ...

Preberi cel članek »

Register Combiners vs. Pixel Shaders

Register Combiners vs. Pixel Shaders

Tokrat bo članek nekoliko bolj tehnične narave. Namen tega članka pa je prikazati kontrast med register combinerji (na primeru NVidine implementacije) in pixel shaderji v DirectX 8.x oziroma klasičnim SetTextureStageState mehanizmom v DirectXu. Za vse, ki se bodo ustrašili, ...

Preberi cel članek »