Novice
arhiv
Članki
Testi
Forum
mali oglasi
teme zadnjih 24h
iskanje
Pravila
Povezave
Ostalo
Anketa
| Če bi v Sloveniji kdo organiziral Barcamp? | | kaj je to barcamp? | 69% |  (827) | | bi prišel vohunit | 17% |  (208) | | me ne zanima | 10% |  (124) | | bi prišel predavat | 3% |  (39) | | Skupaj glasov: 1198 |
Predlagajte vprašanje za naslednjo anketo!
|
|
  Šablone v C++
Avtor: Matjaž Depolli Datum: 18. 7. 2005 |
|
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:
- Naš razred ni navaden, temveč šablonski.
- Šablona ima parametra T in N.
- T je definiran kot razred oziroma class in je lahko osnovni, sestavljeni tip oz. objekt ter celo funkcijski tip.
- 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.
|
|
Za vse na Slo-Techu objavljene prispevke odgovarjajo izključno njihovi avtorji Copyright by Slo-Tech Team Vse pravice pridržane! ISSN 1581-0186 Design (ampak res samo oblika) by Kodesign Alternativne barvne sheme by Exonium Production Do strani dostopate iz:
| Prijava | |
Zadnje novice
Izšla Mathematica 7 (4)
Konica Minolta razvila projektor velikosti palca (4)
Transmeta se prodaja (2)
Microsoft z brezplačnim protivirusnikom (23)
ISS stara 10 let (14)

|