Containers, templates og standard algoritmer i C++

Tags:    c++
<< < 123 > >>
Skrevet af Bruger #5688 @ 10.01.2005

Lad Os Komme I Gang



Teori er godt, men man lærer nu også ved at læse kode. Her er et simpelt program der benytter sig af en speciel container fra STL -- en vector:

Fold kodeboks ind/udKode 


(Dette program løser ikke noget problem der ikke kunne klares uden brug af containere, men bær over med mig for eksemplets skyld).

Vi bruger containeren std::vector, en container der er optimeret til hurtig lineær tilføjelse af elementer. En vectors ydelse er meget ringe hvis man forsøger at benytte den til andet end at tilføje elementer i slutningen af den -- på denne måde virker vector meget som den container der minder mest om et traditionelt array, og hvis man bare har brug for en simpel container, er det også denne jeg vil anbefale.

Det første vi gør i vor main()-funktion er at oprette et vector-objekt, og specificere hvilken type objekt det vil indeholde. Dette skal gøres ved oprettelsen, og typen specificeres i vinkel-parenteser efter containerens typenavn, som det ses i koden. Dette er altså templateinstantieringen.

Vi gennemgår alle de parametre programmet er kaldt med, og gemmer dem i vores vector. Dette sker gennem medlemsfunktionen push_back(), der tager et argument af den type som den relevante vector indeholder (i dette tilfælde konverteres char* til string), og tilføjer (en kopi af) argumentet til slutningen af vectoren. Det skal retfærdigvis siges at en vector har en maksimal størrelse, men denne afhænger af den relevante C++-implementation, og er generelt så høj at den ikke er værd at bekymre sig om. Du vil sandsynligvis løbe tør for virtuel hukommelse før du når at fylde en vector op.

Når vi skal printe indholdet af vor vector benytter vi os af iteratorer, som man passende kan tænke på som en pointer til et givent element i vectoren. Læg især mærke til hvordan vi definerer iteratoren -- vi bruger ikke vores instans af en vector, men derimod selve vector-klassen. Vi tildeler iteratoren værdien der bliver returneret fra Vec.begin(), en funktion der returnerer en iterator til det første element i vectoren. Vi derefererer iteratoren ganske som med en pointer og printer det returnerede element på skærmen, hvorefter vi bruger postincrement-operatoren (++) til at flytte iteratoren til det næste element i vectoren. Vec.end() returnerer en iterator til et punkt efter det sidste element i vectoren, derfor kan vi benytte funktionen til at checke om vores loop skal stoppes, uden at vi mangler at printe det sidste element. Denne itereringsteknik er en af de hyppigst benyttede container-teknikker, idet den både er relativt hurtig, sikker og simpel.

Vær dog opmærksom på at ikke alle containere understøtter denne fremgangsmåde. De har hver deres styrker, hvilket vil blive beskrevet senere.

Iteratorer er grundstenen i STL containers, og dem der gør de forskellige containers til mere end arrays med bounds checking. Det er også især ved iteratorerne at de forskellige containers skiller sig ud.

Iteratorer er den abstraktion der gør generiske algoritmer mulige:

Algoritmer og Containere



Ud over selve containerne, definerer STL også en række praktiske algoritmer til brug i forbindelse med behandling af iteratorer (eller rettere, den data iteratorerne refererer til). Algoritmer opererer ikke, som man skulle tro, på en container -- du kalder altså ikke f.eks. sort(Vec), men derimod sort(Vec.begin(), Vec.end()). Derved kan man nøjes med kun at sortere en mindre del af en container, eller endda sortere almindelige arrays -- pointers kan i næsten alle tilfælde bruges som iteratorer. Jeg vil præsentere brugen af udvalgte algoritmer, og vise en mulig måde de kan implementeres på.

Jeg vil nu demonstrere funktionen sort(). For at forstå kravene en sådan funktion stiller, er det nødvendigt først at forstå at der findes forskellige kategorier af iteratorer, alt efter hvilke funktioner de understøtter (basalt set, hvilken adgang de giver til det underliggende objekt). Kategorierne er:

Input iterator

Denne form for iterator kan flytte til det næste element i containeren (men ikke det forrige), kan få adgang til det underliggende objekt (gennem *-operatoren), follow-pointer-operatoren (->) og er-ikke-lig-med-operatoren (!=). Denne iterator giver også kun read-only adgang til det element der peges på. En iterator der udelukkende har disse operatorer defineret vil skabe compile-errors hvis vi forsøger at bruge andre operatorer. Alle containere i C++'s STL understøtter mindst disse operatorer, hvilket vil sige at alle iteratorer er input-iteratorer. De kan også tilhøre andre iteratorkategorier, forudsat at de opfylder de relevante kategoriers krav, men de er i hvert fald input-iteratorer, idet de opfylder input-iterator-gruppens krav. Hvis man tilknytter en iterator til cin er der tale om en input iterator.

Output iterator

Output-iteratorer er som input-iteratorer, bortset fra at de kun tillader at der skrives til det underliggende objekt, ikke at det læses (f.eks. *it = foo). Derudover er de magen til input-iteratorer. Alle iteratorer i C++'s STL er, ganske som med input-iteratorer, output-iteratorer. En output-iterator ses ofte i forbindelse med fil-output. Du kan skrive til en fil, og du kan rykke fremad i filen, men hvis du kun har åbnet filen til output, kan du naturligvis ikke læse fra den.

Forward Iterator

Disse iteratorer fungerer som input-iteratorer, i den forstand at de kun kan flytte til næste objekt, men til gengæld understøtter de både skrivning og læsning af og til det underliggende element. Som med de to foregående typer, understøtter alle iteratorer i C++'s STL disse krav, og er derfor forward iterators.

Bidirectional Iterator

Birectional iterators skal understøtte alle forward-iterator operatorer, men skal derudover også understøtte at gå til forrige element. Alle containere i STL opfylder kravene til bidirectional iterators.

Random Access Iterator

Denne iterator er den mest kraftfulde i C++'s STL, og kun få containere understøtter den. Basalt set kræver den at man kan udførearitmetik med iteratoren. F.eks. trække to iteratorer fra hinanden -- hvis man f.eks. trækker en iterator der peger på det 3. element fra en iterator der peger på det 8. element, skal man få en iterator der peger på det 5. element. I mange containere ville denne iterator slet ikke give mening, idet der ikke er tale om en lineær container, men snarere en "pøl" af data. Containers der har random access iterators, understøtter også []-operatoren, kendt fra arrays. std::string og std::vector opfylder begge random access iteratorens krav (idet begge containere er lineære repræsentationer af data).

Det virker måske absurd at der findes så primitive iteratorer som input-iterators, idet det jo er svært at se hvorfor nogen iterator skulle være så begrænset, og hvad man overhovedet kan bruge den til. En sådan iterator ville være praktisk, hvis man f.eks. havde en kompleks database, hvor man ikke ville være det i stand til at definere at et objekt kommer "efter" et andet, hvilket vil sige at der bliver byttet om på rækkefølgen af data hver gang man opretter en iterator. I et sådant tilfælde ville en input iterator være praktisk (eller nødvendig), idet man ved at inkrementere den nok gange eventuelt ville læse hele databasen (hvis klassen da havde et system der forhindrer iteratoren i at pege på samme data to gange).

sort() kræver random access adgang via random access iteratorer, og compileren vil melde fejl hvis man forsøger at kalde funktionen med iteratorer der ikke er i denne klasse.

Med denne teori i baghovedet burde det være til at forstå hvordan sort() skal bruges:

Fold kodeboks ind/udKode 


Udover sort() selv, er der intet nyt i dette program. Programmet tager EOF (Ctrl-D på de fleste operativsystemer) for at afbryde inputsekveksen. for-loopet er også mere kompakt end i det forrige eksempel, men princippet er det samme (læg dog mærke til at der benyttes en read-only const_iterator -- denne bør altid benyttes, når man ikke vil ændre på iteratorens underliggende objekt, ganske som med traditionel const).

sort() tager to iteratorer som parametre, der definerer hvilken sekvens af objekter der skal sorteres (praktisk, hvis man ikke ønsker at sortere hele containeren). Eftersom vores vector består afints, har compileren ikke svært ved at sammenligne de respektive elementer, men hvis vi havde en vector der indeholdt klasser der ikke kunne sammenlignes med '<'-operatoren , eller vi bare ønskede at sortere på en anden måde, kunne vi bruge en funktion som det tredje argument. En sådan funktion kaldes et prædikat(predicate), og returnerer bool. F.eks:

Fold kodeboks ind/udKode 


Hvor compare() er en funktion der tager to argumenter af samme type som Vec indeholder, og som returnerer true, hvis det andet argument er "større" (hvad dette betyder i denne sammenhæng, afhænger af programmøren) end det første.

F.eks:

Fold kodeboks ind/udKode 


Dette vil resultere i at alle de tal der kan deles med 2 (alle runde tal), bliver placeret i starten af vectoren.

Man bør altid bruge denne sort() funktion, frem for qsort(), som C++ har arvet fra C.

En anden generisk algoritme er fill(). Den bruges således:

Fold kodeboks ind/udKode 


Denne algoritme vil tildele alle elementer i intervallet [begin;end[ værdien value.

Man kunne forestille sig fill() være implementeret således:

Fold kodeboks ind/udKode 


Bemærk at denne algoritme kan benyttes både med pointers og rigtige iteratorer. Betragt:

Fold kodeboks ind/udKode 


Ganske elegant. Selvom dette program er simpelt, demonstrerer det alligevel de grundlæggende ideer bag generiske algoritmer.

Hvilke iteratorer kræver de to funktioner så? Det er tydeligt at fill() ikke kan klare sig med read-only iteratorer, men eftersom de eneste funktioner der bliver benyttet er operator*, operator= og postfix operator++ burde en output iterator være acceptabel. print_container kan klare sig med en input iterator -- så simpel er funktionen.

Prædikater og Funktionsobjekter



Ovenfor nævnte jeg kort hvorledes man kunne specificere sin egen sammenligningsfunktion i sort(). Jeg brugte der en funktionspointer, men det er langt mere normalt at bruge funktionsobjekter (også kendt som functors). Disse er objekter hvor operator() er defineret:

Fold kodeboks ind/udKode 


For at vise hvordan disse fungerer, ser vi på en logisk implementation af find() og find_if(), hvor find_if() bruger et prædikat:

Fold kodeboks ind/udKode 


find() og find_if() returnerer den sidste iterator i sekvensen, hvis den ønskede værdi ikke kan findes, eller iteratoren der refererer til den ønskede værdi i containeren, hvis den kan findes. find_if() bruger blot et simpelt prædikat til dette. Det trick jeg bruger for at finde hvilken plads i sekvensen resultatet har, er noget jeg kan gøre fordi jeg i dette eksempel bruger pointers og arrays. Pointers er effektivt det samme som random-access iterators. Dette eksempel er interessant, fordi det viser at man, uden at udvide selve algoritmen, kan få en find() til at lede efter to forskellige værdier. I princippet er der ingen grænser for hvor kompliceret logik der kan indbygges i et prædikat. Se f.eks. dette, der leder efter to på hinanden følgende værdier:

Fold kodeboks ind/udKode 


Der er adskillige fordele ved at bruge function objects frem for function pointers. For det første er det trivielt for compileren at inline funktionskaldende, hvorved man sparer den overhead der er associeret med ethvert funktionskald, og ydermere kan funktionen gemme information om sig selv imellem kaldende, uden brug af statisk data.

max_element() og min_element returnerer hver en iterator der refererer til hhv. det største og det mindste element i sekvensen. Disse to algoritmer kunne implementeres, og bruges, således:

Fold kodeboks ind/udKode 


Jeg bruger en pointer til en iterator, for jeg kan ikke være sikker på at en iterator er et letvægtsobjekt, så jeg vil helst undgå dyr initialisering. Det kan også lade sig gøre at lave en max_element_if(), hvor et binært prædikat (en funktion der tager to parametre) benyttes:

Fold kodeboks ind/udKode 


copy() er en af de mest fleksible algoritmer i STL. Dens mest åbenlyse brug, er kopiering af en sekvens til en ny container:

Fold kodeboks ind/udKode 


copy() er en funktion man skal passe på med. Her ses det at jeg opretter to vector-objekter, hver med plads til fem elementer, for derefter at tildele en værdi til elementerne i src_vec. copy() fungerer ved at hvert element i den angivne sekvens (her src_vec.begin(), src_vec.end()) tildeles til destinationen (her dest_vec.begin()), som inkrementeres efter hver værditildeling, med det resultat at iteratoren bevæger sig til det næste element i containeren. Det er her man skal passe på, for copy() kender ikke til destinationens størrelse, og vil uden videre lade destinationsiteratoren vandre ind i hukommelse uden for containeren. Derfor specificerer jeg eksplicit at dest_vec har fem elementer -- så er jeg sikker på at den ikke overflower. Dette holder jo naturligvis ikke, derfor findes der en wrapper-klasse, der fungerer som en iterator, men som kalder push_back på den relevante container, når der tildeles til iteratoren. Deklarationen af dest_vec og kaldet af copy() kan altså erstattes med:

Fold kodeboks ind/udKode 


Ligeledes findes der front_inserter, der, som navnet antyder, bruger push_front(), frem for push_back().

Jeg bruger i Fig. 4.5 en klassisk for-konstruktion til at printe indholdet af containeren... det virker, men det er ikke synderligt elegant. Her kan copy() og opfindsom brug af wrapper-iteratorer også hjælpe til. Den iteratorwrapper vi her skal bruge hedder ostream_iterator, der er at finde i <iterator. Den tager to argumenter, den stream den skal skrive til (i vores tilfælde, cout), og en seperator, der udskrives imellem hvert element. ostream_iterator er en template, og den instantieres med den type som den modtager. Vi kan erstatte for-konstruktionen med følgende kald af copy():

Fold kodeboks ind/udKode 


Her vises en revideret udgave af programmet i Fig. 4.5, der har de to forbedringer jeg nævnte her (og som ikke længere behøver at specificere størrelsen på sine vector-objekter eksplicit):

Fold kodeboks ind/udKode 


ostream_iterator skal fungere som en iteratoremulator, men i princippet er der jo ikke meget iterator over den. Der er intet element at rykke videre til, og det ville ikke give mening at rykke tilbage. Alligevel er ostream_iterator en output iterator. operator= for et ostream_iterator objekt er som at kalde operator<< for den relevante stream. Det er ikke specificeret hvad der sker når man kalder postfix operator++ for et ostream_iterator-objekt, men en given implementation kan have valgt at det først er når denne kaldes, at der rent faktisk skrives til den relevante ostream.

back_inserters og ostream_iterators er abstraktioner der kun er mulige pga. C++'s elegante template, stream og iterator-design. Som det sås ovenfor, ved Fig. 4.5 , skal man dog også passe på, for der er grænser for hvor meget de individuelle algoritmer ved om de objekter de behandler, hvilket er årsagen til at wrappere som ostream_iterator og back_inserter eksisterer. Som algoritmeprogrammør kan det dog være nyttigt at kende til visse detaljer omkring iteratorerne, f.eks. hvilke typer de refererer til, og dette klares via iterator_traits-klassen. iterator_traits er en template-klasse indeholdende typedef's. Idéen er at man, når man laver en ny iterator, laver en template specialization, så iterator_traits også fungerer for denne. Via template specialization fungerer klassen også med pointers. Her er definitionen:

Fold kodeboks ind/udKode 


Hvis en iterator er en klasse i sig selv, vil den, hvis den har defineret de rigtige typer i sin deklaration, automatisk virke i iterator_traits. Hvis ikke, kan man lave template specialization, såsom det kan ses at det er gjort med pointers. random_access_iterator_tag er en "tom" klasse, der udelukkende er beregnet til at blive brugt til template specialization og lignende, og den indikerer at iteratoren er en random access iterator. Ligeså findes der klasserne input_iterator_tag, output_iterator_tag, forward_iterator_tag og bidirectional_iterator_tag. ptrdiff_t er den type man får når man trækker to pointere fra hinanden. Som regel er dette en int.

Fold kodeboks ind/udKode 


I Fig. 4.10 er der vist en funktion der kan producere vector-objekter fra input iteratorer. Den type en given vector skal indeholde skal defineres ved templateinstantiering, ved compile-time, så her bruger jeg iterator_traits til at få information om den type iteratorerne refererer til. Jeg bruger eksplicit typename, for at give compileren et hint om at jeg mener en type, og ikke et udtryk. typename er nødvendigt når templateparametre benyttes på en så relativt kompleks måde. Der vises hvordan en container af typen list kan omdannes til en vector, men funktionen burde fungere med enhver gyldig sekvens. list er en container jeg ikke har nævnt før, og den vil blive beskrevet i næste sektion.




<< < 123 > >>

Hvad synes du om denne artikel? Giv din mening til kende ved at stemme via pilene til venstre og/eller lægge en kommentar herunder.

Del også gerne artiklen med dine Facebook venner:  

Kommentarer (10)

User
Bruger #4803 @ 10.01.05 22:48
meget lang.. eventuelt split den op i 2? 3? :S
User
Bruger #5688 @ 10.01.05 22:54
Den kan ikke splittes op uden at forstyrre sammenhængen. Det er et meget komplekst emne.
User
Bruger #6649 @ 10.01.05 22:57
En rigtig god artikel... den har fortjent en femmer
User
Bruger #479 @ 11.01.05 16:32
Flot artikel... du får en femmer herfra.

Jeg håber du senere får tid til at skrive lidt mere om templates.
User
Bruger #5779 @ 12.01.05 14:45
god artikel
User
Bruger #5688 @ 16.01.05 21:30
Efterfølgeren bliver ikke nær så stor, men den skal nok komme engang.
User
Bruger #3470 @ 23.01.05 15:00
God artikel, lærte godt nok ikke noget nyt, men håber at andre gjorde ;-)
Selvom jeg ikke lærte noget, så du får skam stadig 5 :-P

Jeg vil opfordre alle andre til at læse meget mere om dette emne, for der er rent faktisk stor ydelsesmæssig forskel på de forskellige containers. Det blev der ikke nævnt særligt meget om i denne artikel, så jeg synes at jeg burde fremhæve det her.
User
Bruger #601 @ 19.03.05 08:48

Dejligt med en artikel som bevæger sig ud over det rene begynder niveau. Eneste minus - som forfatteren ikke kan gøre noget ved - er at man ikke kan hente artiklen i fx. PDF format ;-).

Forsæt endelig det gode arbejde.
User
Bruger #13085 @ 09.01.08 08:40
Jeg er ny bruger

Udemærket artikel.

Jeg har kun skimmet den, da jeg har nogle ret velskrevne, uddybende refs til STL/templates på engelsk. Jeg vil give artiklen 4. Fordi: Indholdet er solidt, men strukturen er lidt forvirrende, og den er måske mere forvirrende for folk, der intet kender til emnet.

Jeg vil som nogle andre skrev ovenfor også klart mene, at det ville være fedt, hvis forfatteren skrev videre på artiklen. MEN Det BEHØVER vel ikke egentlig ikke gøres af den samme person? Måske kunne NOGEN føre artiklen videre, hvis forfatteren ikke har tid. Rigtig "open source"-stil!

Jeg har surfet lidt rundt her på siden og synes, at der mangler en rigtig "børnehave"-C++ tutorial.

Noget kunne jo "for skæg" sende et "manuskript" ind til censur og se, hvad "redaktøren" siger?

Personligt ville jeg sagtens kunne skrive en "lille" tekst, som indtroducerer sproget C/C++ på en MEGET, MEGET pædagogisk måde, så de sidste 10%, der er bange for programmering kan komme med.

Idé?

User
Bruger #8985 @ 20.04.09 23:59
Nicolai Lystner Fersner: Hvad er forskellen på ydelse mellem de forskellige containere? Hvilke containere snakker vi om? God kommentar, lærte godt nok ikke noget nyt, og tvivler andre gjorde.

Stefan E. Nielsen: Du kan jo læse lidt af artiklen, holde pause, arbejde med det, du har lært, og så læse videre. På den måde vil artiklen ikke virke så lang.

Christian Dyrnesli: Hvad holder dig tilbage? Det virker som en god idé, synes jeg.

Troels Henriksen: God artikel! Jeg har arbejdet med templates før, men du har vist mig nogle metoder at anvende dem på, jeg end ikke havde overvejet.
Du skal være logget ind for at skrive en kommentar.
t