Klase kolekcija

Poglavlje 9
Klase kolekcija
U prethodna dva poglavlja vidjeli smo kako se informacije mogu čuvati u nizovima i
popisima te kako se mogu sortirati, pretraživati i obrađivati pomoću LINQ-a. Iako su
sekvencijalni popisi i pravokutni nizovi važni, oni ne ispunjavaju sve moguće zahtjeve
koje biste mogli imati oko čuvanja i strukturiranja podataka. Zbog toga ćemo u ovom
zadnjem poglavlju u kojem će biti riječ o kolekcijama razmotriti još neke klase kolekcija koje .NET kostur nudi.
Rječnici
Rječnik (engl. dictionary) je kolekcija koja omogućava da tražite informacije pridružene nekoj vrsti vrijednosti. .NET ovu vrstu kolekcije naziva rječnik jer podsjeća na
uobičajene tiskane rječnike: informacije su u njima strukturirane tako da je vrlo lako
pronaći određenu riječ. Ako znate koju riječ tražite, vrlo brzo ćete je pronaći čak iako
se u rječniku nalazi više desetina tisuća riječi. Informacije koje ćete dobiti kada pronađete riječ ovise o vrsti rječnika. On može pružati informacije o značenju, ali postoje
i drugi rječnici koji pružaju informacije poput etimologije ili citata.
Isto tako, .NET rječnici su strukturirani tako da omogućavaju brzo i jednostavno
pronalaženje unosa. Ta sintaksa izgleda vrlo slično sintaksi za pristupanje nizu, ali
umjesto broja, indeks može biti i nešto drugo, primjerice, znakovni niz, kao u primjeru
9-1.
Primjer 9-1. Traženje unosa u rječniku.
string definition = myDictionary["sea"];
Kao što se tiskani rječnici razlikuju po tome kakvu ćete informaciju dobiti kada pronađete riječ, tako se i .NET rječnici razlikuju po informacijama koje pružaju. Tip Dictionary iz imenskog prostora System.Collections.Generic je generički tip i omogućava
da zadate tip i za ključ (vrijednost koja se koristi kao indeks) i za vrijednost koja mu je
pridružena. (Postoje određena ograničenja vezana za tip ključa. Da biste saznali više,
299
pogledajte izdvojeni odlomak na sljedećoj stranici.) Primjer 9-1, koji modelira klasični
tiskani rječnik za indeks koristi znakovni niz i kao rezultat također očekuje znakovni
niz. Zbog toga će myDictionary biti definirano kao u primjeru 9-2.
Primjer 9-2. Rječnik s ključem i vrijednošću tipa string.
Dictionary<string, string> myDictionary = new Dictionary<string, string>();
Ključevi, uspoređivanje i hash funkcije
Da bi mogli brzo pronalaziti unose, rječnici nameću nekoliko ograničenja za ključeve. Prvo, ključ unosa u rječniku ne smije se mijenjati na način koji može utjecati na
uspoređivanje. (To najčešće znači da nikada ne biste trebali mijenjati ključ. Tehnički
je moguće izgraditi tip kod kojeg određene vrste promjena nemaju utjecaja na uspoređivanje koje izvodi metoda Equals. Takve promjene su nevidljive za rječnik.) Drugo,
mora pružati dobru hash funkciju.
Da biste razumjeli prvi razlog – da se radi uspoređivanja ključ ne smije mijenjati – razmislite što bi se dogodilo da to učinite u tiskanom rječniku. Pretpostavimo da ste u
rječniku pronašli riječ pogreška, zatim je prekrižili i napisali značajka. Tu riječ više ne
biste mogli pronaći na uobičajeni način – unos je bio postavljen na dobro mjesto kada
je ključ bio pogreška. Korisnici koji budu tražili značajka neće se sjetiti da tu riječ traže
na mjestu gdje ste je dodali. Isto vrijedi i za .NET rječnik – da bi pretraživanje moglo
biti brzo rječnici formiraju unutrašnju strukturu na temelju ključeva koji su unosima
dodijeljeni kada su dodani u rječnik. Oni ne mogu znati kada ste promijenili ključ. Ako
to zaista morate raditi, obrišite cijeli unos i zatim ga dodajte s novim ključem. Tako ćete
rječniku omogućiti da ponovo izgradi strukturu za pretraživanje podataka.
Ovaj zahtjev najlakše je ispuniti korištenjem nepromjenjivog tipa, kao što je string,
ili nekog od ugrađenih numeričkih tipova.
Drugi zahtjev – da tipovi trebaju pružati dobru hash funkciju – nešto je manje očigledan i povezan je s načinom na koji kolekcije rječnika implementiraju brzo pretraživanje. Osnovna klasa System.Object definira virtualnu metodu GetHashCode kojoj je
posao da vrati int čija vrijednost otprilike predstavlja vrijednost objekta. GetHashCode
mora biti konzistentna s metodom Equals (također je definirana u System.Object)
– dva objekta ili vrijednosti koji su prema Equals jednaki moraju vratiti isti hash kod.
To su pravila kojih se morate pridržavati jer u suprotnom rječnici neće raditi.
Ovo ujedno znači da, ako premostite Equals, morate premostiti i GetHashCode i obrnuto.
Pravila za hash kodove za stavke koje nisu jednake su fleksibilnija. U idealnom slučaju
različite stavke trebale bi vratiti različite hash kodove, ali to nije uvijek moguće. long
može imati bilo koju od nekoliko triliona vrijednosti a hash kôd je int koji može imati
samo nekoliko milijardi mogućih vrijednosti. Dakle, neizbježno je sukobljavanje hash
vrijednosti – različite vrijednosti dat će isti hash kôd. Na primjer, long vraća isti hash
kôd za 1 i 4294967296.
Implementacije GetHashCode trebale bi pokušati minimizirati sukobe hash vrijednosti.
Razlog je što rječnici koriste hash kodove kako bi otkrili gdje da smjeste unos.
300 | Programiranje C# 4.0
U analogiji tiskanog rječnika hash kôd bio bi ekvivalentan broju stranice na kojoj se
rječnik nalazi. Sukob hash vrijednosti znači da se unosi nalaze na različitim stranicama pa rječnik mora utrošiti dodatno vrijeme da ih pronađe. Što je manje sukoba
hash vrijednosti to rječnici brže rade.
Ako za ključ koristite ugrađeni numerički tip kao što je int, ili ako koristite string, sve
ovo možete zanemariti jer ti tipovi pružaju dobre hash kodove. O tome trebate voditi
računa samo ako planirate koristiti prilagođeni tip koji definira vlastito značenje jednakosti (tj. koji premošćuje Equals)
Kada počnete koristiti tipove kolekcija koji zahtijevaju više argumenata generičkog
tipa, kao što je ovaj, zadavanje punog imena tipa u deklaraciji varijable i ponovo u konstruktoru počinje izgledati pomalo preopširno. Pa, ako koristite rječnik sa lokalnom
varijablom možda će vam više odgovarati da koristite ključnu riječ var predstavljenu
u C# 3.0, kao što je prikazano u primjeru 9-3.
Primjer 9-3. Izbjegavanje ozljeda zbog pretjeranog ponavljanja pokreta pomoću ključne riječi var.
var myDictionary = new Dictionary<string, string>();
Sjetite se da ključna riječ var jednostavno traži od jezika C# da otkrije tip varijable
analiziranjem izraza u kojem se varijabla inicijalizira. Zbog toga je primjer 9-3 potpuno ekvivalentan primjeru 9-2.
Kao što nizovi i drugi popisi mogu biti inicijalizirani popisom vrijednosti u vitičastim
zagradama, tako možete pružiti i popis za inicijaliziranje rječnika. Kao što prikazuje primjer 9-4, za svaki unos morate pružiti ključ i vrijednost. Zbog toga je svaki unos sadržan
u ugniježđenim zagradama kako bi parovi ključ/vrijednost bile jasno odvojeni.
Primjer 9-4. Popis za inicijaliziranje rječnika.
var myDictionary = new Dictionary<string, string>()
{
{ "dog", "Not a cat." },
{ "sea", "Big blue wobbly thing that mermaids live in." }
};
Čuvanje pojedinačnih stavki u rječniku koristi sintaksu sličnu sintaksi rječnika:
myDictionary["sea"] = "Big blue wobbly thing that mermaids live in.";
Kao što možda nagađate, rječnici se oslanjanju na značajku indeksera koju smo predstavili u prethodnom poglavlju.
Uobičajene primjene rječnika
Rječnici su vrlo koristan alat jer se situacije koje rješavaju – kada je jedna vrijednost
pridružena drugoj – susreću vrlo često. Toliko se često koriste da je korisno pogledati
neke uobičajene konkretne primjere.
Poglavlje 9: Klase kolekcija | 301
Traženje vrijednosti
Računalni sustavi često koriste misteriozne identifikatore na mjestima na kojima bi
ljudi obično koristili imena. Na primjer, zamislite sustav koji upravlja pacijentima u
bolnici. On bi morao održavati popis zakazanih pregleda a koristilo bi ga osoblje na
prijemu kako bi upućivalo pacijente u odgovarajuću ambulantu. Sustav zbog toga
mora znati koje sve zgrade i odjeli postoje – radiologija, fizijatrija itd.
Sustav će za takve entitete koristiti neku vrstu jedinstvenog identifikatora kako bi se
izbjegle dvosmislice i osigurao integritet podataka. Ali, korisnici sustava vjerojatno će
htjeti znati da je pregled zakazan u zgradi Dr. Marvin Munroe a ne u zgradi s identifikatorom 49. Zbog toga će korisničko sučelje morati pretvoriti identifikator u tekst.
Informacije o tome koji identifikator odgovara određenoj zgradi pripadaju u bazu
podataka ili možda konfiguracijsku datoteku. Popis ne želite smjestiti u veliki iskaz
switch u kodu jer bi to teško podržavalo veći broj korisnika a također bi trebalo objavljivati novu verziju softvera svaki put kad bi se izgradila nova zgrada ili promijenilo
ime postojeće.
Korisničko sučelje moglo bi pretraživati bazu podataka svaki put kada treba prikazati
informacije o zakazanom pregledu, ali u tom pristupu postoji nekoliko problema.
Prvo, računalo na kojem se izvršava korisničko sučelje možda nema pristup bazi podataka – ako je napisano kao klijentska aplikacija u WPF ili Windows Forms, velika je
vjerojatnost da neće – jer mnoge tvrtke smještaju baze podataka iza vatrozida i teško
im je pristupati čak i iz interne mreže. Čak i kada bi moglo pristupati bazi podataka,
slanje zahtjeva bazi za svako polje povezano s identifikatorom zahtijeva određeno vrijeme za izvršavanje. Ako prozor sučelja sadrži nekoliko takvih polja, kašnjenje prikaza
moglo bi biti primjetno. Sve je to nepotrebno za podatke koji se rijetko mijenjaju.
Rječnik bi ovdje bio bolje rješenje. Kada se aplikacija pokrene, može učitati rječnik za
pretvaranje identifikatora u ime. Prevođenje identifikatora u ime koje se može prikazati svodi se samo na jednostavan red koda:
string buildingName = buildingIdToNameMap[buildingId];
Kako ćete rječnik učitavati ovisi o tome gdje su podaci spremljeni. Ako dolaze iz datoteke, baze podataka, Web usluge ili nekog drugog mjesta, za inicijaliziranje rječnika
možete koristiti LINQ. U dijelu „Rječnici i LINQ“ vidjet ćete kako se to radi.
Spremanje u cache memoriju
Rječnici se često koriste za spremanje informacija koje se sporo dobivaju ili stvaraju.
One se mogu smjestiti u rječnik kada se prvi put učita što aplikaciji omogućava da
izbjegne troškove kada se informacije sljedeći put zatraže. Na primjer, zamislite da je
liječnik primio pacijenta i želi pogledati informacije o nalazima i zahvatima kojima
je bio podvrgnut. To obično zahtijeva dohvaćanje zapisa s nekog poslužitelja. Vidjet
302 | Programiranje C# 4.0
ćete da će aplikacija biti mnogo brža ako se zapisi sačuvaju na strani klijenta nakon
što su jednom primljeni, kako se ne bi morali iznova dohvaćati dok se liječnik pomiče
kroz popis nalaza.
Ova ideja vrlo je slična primjeni pretraživanja koju smo ranije opisali, ali uz dvije važne
razlike. Prvo, cache obično zahtijeva nekakvo pravilo po kojem se odlučuje kada se
podaci brišu iz cache memorije. Ako u nju samo dodajemo nove podatke i ne brišemo
stare, s vremenom će zauzimati sve više memorije računala, što će usporiti program
umjesto da ga ubrza. Odgovarajuće pravilo za brisanje stavki iz cache memorije bit će
specifično za aplikaciju. U aplikaciji za pregledavanje podataka o pacijentima najbolji
pristup bi bio isprazniti cache memoriju čim liječnik počne zahtijevati informacije o
drugom pacijentu jer to sugerira da je primio drugog pacijenta i neće se uskoro vraćati
na starog. Ali, to pravilo radi samo zbog načina na koji se taj sustav koristi – drugi
sustavi možda će zahtijevati drugačije mehanizme. Drugi popularni heuristički pristup je postaviti gornju granicu ukupnog broja stavki u cache memoriji ili ukupne
količine podataka u memoriji i uklanjati ih na temelju podatka o tome kad im je zadnji
put pristupano.
Druga razlika između upotrebe rječnika za čuvanje podataka u cache memoriji i pretraživanja unaprijed učitanih podataka je da se spremanje u cache memoriju koristi
u situacijama kada se podaci češće mijenjaju. Popis zemalja u svijetu se ne mijenja
previše često dok se dosje pacijenta može mijenjati vrlo često, pogotovo ako je pacijent
na liječenju u bolnici. Prema tome, kada se kopije podataka spremaju lokalno u cache
memoriju, u rječnik, morate naći načina da riješite problem zastarjelih podataka. (Na
primjer, iako lokalno spremanje podataka o pacijentu može biti korisno, aplikacija
mora znati što da radi ako novi podaci o pacijentu postanu dostupni dok je u ordinaciji
s liječnikom.) Kao i pravila za brisanje podataka iz cache memorije, prepoznavanje
zastarjelih podataka zahtijeva logiku specifičnu za aplikacije.
Na primjer, neki podaci se možda nikada neće mijenjati. Računovodstvo često radi na
taj način jer, čak i ako se otkrije da su neki podaci netočno, najčešće se ne mijenjaju.
Propis obično zahtijevaju da se problem riješi tako da se doda novi računovodstveni
dokument koji ispravlja stari. Za takve vrste unosa znate da kopija iz cache memorije
nikad neće biti zastarjela. (Može postojati noviji zapis koji zamjenjuje zapis koji imate
u cache memoriji, ali će taj zapis iz cache memorije biti usklađen s onim što za njega
stoji u bazi podataka.)
Ponekad se može izvesti relativno jeftin test kako bi se utvrdilo jesu li podaci iz cache
memorije konzistentni s informacijama na poslužitelju. HTTP protokol to podržava.
Klijent može poslati zahtjev sa zaglavljem If-Modified-Since u kojem stoji datum za
koji se zna da su informacije iz cache memorije tada bile svježe. Ako na poslužitelju
nema svježijih informacija, on šalje kratku poruku koja to potvrđuje i ne šalje podatke.
Web preglednici koriste tu tehniku kako bi se stranice koje ste ranije pregledavali učitavali brže a da vam pritom uvijek bude prikazana najsvježija verzija stranice.
Poglavlje 9: Klase kolekcija | 303
Ali, možda ćete jednostavno morati nagađati. Ponekad će za rješavanje problema
zastarjelih podataka najbolje služiti pravilo: „Ako je zapis u cache memoriji stariji od
20 minuta uzet ćemo svježe podatke s poslužitelja.“ S takvim pristupom morate biti
vrlo oprezni. Nagađanje ponekad može dovesti do toga da spremanje u cache memoriju ne poboljšava performanse aplikacije ili da su podaci prestari da bi bili korisni.
Bez obzira na detalje pravila za brisanje iz cache memorije i prepoznavanja zastarjelih
podataka, pristup će izgledati slično kao u primjeru 9-5. (Tip Record u primjeru nije
tip iz biblioteke klasa. Koristimo ga samo radi ilustracije – u stvarnosti će to biti klasa
za podatke koje želite spremati u cache memoriju.)
Primjer 9-5. Korištenje rječnika za spremanje u cache memoriju.
class RecordCache
{
private Dictionary<int, Record> cachedRecords =
new Dictionary<int, Record>();
public Record GetRecord(int recordId)
{
Record result;
if (cachedRecords.TryGetValue(recordId, out result))
{
// Stavka je pronađena ali je li svježa?
if (IsStale(result))
{
result = null;
}
}
if (result == null)
{
result = LoadRecord(recordId);
// Dodaje novi zapis u cache.
cachedRecords[recordId] = result;
}
DiscardAnyOldCacheEntries();
return result;
}
private Record LoadRecord(int recordId)
{
... Ovdje ide kôd za učitavanje zapisa ...
}
private bool IsStale(Record result)
{
... Ovdje ide kôd koji utvrđuje je li zapis zastario ...
}
304 | Programiranje C# 4.0
private void DiscardAnyOldCacheEntries()
{
... Ovdje ide kôd pravila za uklanjanje iz cache memorije ...
}
}
Zamijetite da ovaj kôd ne koristi indekser za pronalaženje stavke u rječniku. Umjesto
njega koristi metodu TryGetValue. Ona se koristi kada niste sigurni sadrži li rječnik
unos koji tražite – u ovom slučaju unos neće biti prisutan kada ga prvi put zatražimo.
(Rječnik će izbaciti iznimku ako koristimo indekser za traženje vrijednosti koja nije
prisutna.) TryGetValue vraća true ako je unos za zadani ključ pronađen a false ako
nije. Zamijetite da njen drugi argument koristi kvalifikator out koji ona dalje koristi za
vraćanje unosa, ako je pronađen, odnosno prazne reference, ako nije pronađen.
Možda se pitate zašto TryGetValue jednostavno ne upotrijebi povratnu
vrijednost null kako bi naznačila da zapis nije pronađen umjesto ove,
pomalo klimave, kombinacije s bool povratnom vrijednosti i argumentom out? To ne bi radilo s vrijednosnim tipovima jer ne mogu biti null.
Rječnici mogu sadržavati referentne tipove ili vrijednosne tipove.
Dinamička svojstva
Druga česta primjena rječnika je kada želite nešto što radi kao svojstvo ali da skup
dostupnih svojstava nije nužno fiksan. Na primjer, WCF je projektiran da šalje i prima
poruke korištenjem različitih mrežnih tehnologija od kojih svaka može imati jedinstvene karakteristike. Zbog toga WCF definira neka uobičajena svojstva i metode za
rad s aspektima komunikacije koji su zajednički za većinu scenarija, ali i pruža rječnik
dinamičkih svojstava za potrebe specifičnih situacija prijenosa.
Na primjer, ako koristite WCF i komunikaciju temeljenu na protokolu HTTP, možda
ćete htjeti da kôd klijenta može mijenjati zaglavlje User-Agent. Ono je specifično za
HTTP pa WCF ne pruža svojstvo za njega u okviru svog programskog modela jer
većini drugih komunikacijskih protokola nije potrebno. To zaglavlje možete kontrolirati pomoću dinamičkog svojstva dodanog preko rječnika Properties WCF tipa
Message (primjer 9-6).
Primjer 9-6. Zadavanje dinamičkog svojstva.
Message wcfMessage = CreateMessageSomehow();
HttpRequestMessageProperty reqProps = new HttpRequestMessageProperty();
reqProps.Headers.Add(HttpRequestHeader.UserAgent, "my user agent");
wcfMessage.Properties[HttpRequestMessageProperty.Name] = reqProps;
Poglavlje 9: Klase kolekcija | 305
C# 4.0 uvodi alternativni način da podržava dinamička svojstva. kroz
ključnu riječ dynamic (opisat ćemo je u poglavlju 18). To omogućava
korištenje uobičajene C# sintakse za pristupanje sa svojstvima čija se
dostupnost utvrđuje u vrijeme izvršavanja. Mogli biste pomisliti da
dynamic čini rječnike nepotrebnima. Ona se u praksi koristi samo u
interakciji s dinamičkim sustavima programiranja kakvi su skriptni
jezici, pa zbog toga nije temeljena na .NET mehanizmu rječnika.
Rijetki nizovi
Zadnji primjer primjene rječnika koji ćemo pogledati je pružanje učinkovite pohrane
za rijetke nizove (engl. sparse array) Rijetki niz se indeksira cijelim brojevima, kao i
obični niz, ali samo mali dio njegovih elemenata sadrži vrijednost različitu od podrazumijevane. Za brojčani tip elemenata to znači da niz sadrži uglavnom nule a za referentni tip uglavnom null vrijednosti.
To može biti korisno npr. u proračunskim tablicama. Kada započnete novu tablicu,
ona izgleda kao veliki niz ćelija. Ali, podaci se ne čuvaju za sve te ćelije. Upravo sam
pokrenuo Microsoft Excel, pritisnuo Ctrl-G i upisao $XFD$1000000 da bih se pomaknuo u tu ćeliju i na kraju upisao vrijednost. Pomaknuo sam se u zadnji stupac koji
Excel dopušta i u milijunti red. Iako ova tablica sadrži više od 16 milijardi ćelija, njena
datoteka ima samo 8 KB. Ona je tako mala jer ne sadrži sve ćelije – u njoj se čuvaju
samo informacije o ćelijama koje sadrže podatke.
Ova proračunska tablica je rijetka – najvećim dijelom je prazna – i koristi reprezentaciju koja učinkovito koristi prostor kada su podaci rijetki.
Ako pokušate izraditi pravokutni niz sa 16384 stupca milijun redova bit će izbačena
iznimka jer će takav niz premašiti najveću dopuštenu veličinu .NET niza od 2 GB.
Novi niz uvijek sadrži podrazumijevane vrijednosti za sve elemente, pa su informacije koje sadrži uvijek na početku rijetke – rijetkost je karakteristika podataka a ne
mehanizma pohrane, ali činjenica da ne možemo izraditi toliko velik novi niz govori
da uobičajeni nizovi ne čuvaju efikasno rijetke podatke.
Ne postoji ugrađeni tip projektiran posebno za čuvanje rijetkih podataka, ali možemo
koristiti rječnik. Primjer 9-7 koristi rječnik za spremanje jednodimenzionalnog rijetkog niza elemenata tipa double. Koristi long kao tip argumenta indeksa kako bi se
omogućio rast niza do logičke veličine, koja je veća nego što bi to bilo moguće s int,
koji ima maksimum na oko 2,1 milijardu.
Primjer 9-7. Rijedak niz brojeva.
class SparseArray
{
private Dictionary<long, double> nonEmptyValues =
306 | Programiranje C# 4.0
new Dictionary<long, double>();
public double this[long index]
{
get
{
double result;
nonEmptyValues.TryGetValue(index, out result);
return result;
}
set
{
nonEmptyValues[index] = value;
}
}
}
Zamijetite da ovaj primjer provjerava povratnu vrijednost iz TryGetValue. To radi zato
što, kada ne uspije pronaći unos, postavlja rezultat na podrazumijevanu vrijednost.
U slučaju tipa double to je 0, a 0 je ono što želimo vratiti za unos čija vrijednost još
nije zadana.
Sljedeći kôd koristi klasu SparseArray:
SparseArray big = new SparseArray();
big[0] = 123;
big[10000000000] = 456;
Console.WriteLine(big[0]);
Console.WriteLine(big[2]);
Console.WriteLine(big[10000000000]);
On postavlja vrijednost prvog elementa i elementa s indeksom deset milijardi – to
jednostavno nije moguće učiniti s običnim nizom. Ovdje to radi dobro, uz minimalnu
potrošnju memorije. Kod ispisuje vrijednosti za tri indeksa, uključujući jedan koji nije
postavljen. Evo rezultata:
123
0
456
Čitanje vrijednosti koja nije postavljena vraća podrazumijevanu vrijednost 9, što je i
traženo.
Neki nizovi bit će rjeđi od drugih i neizbježno ćete se naći u situaciji
u kojoj je niz prerijedak i efikasnije je upotrijebiti veliki niz umjesto
rječnika. Teško je reći gdje pada granica između ova dvije tehnike jer
to ovisi o faktorima poput tipa i količine podataka te raspona indeksa.
Kao i kod svih odluka o implementaciji na temelju efikasnosti, trebate
usporediti performanse s jednostavnijim pristupom kako biste saznali
jeste li ostvarili dobitak koji ste očekivali.
Poglavlje 9: Klase kolekcija | 307
IDictionary<TKey, TValue>
Svi primjeri koje ste do sada vidjeli koristili su tip Dictionary definiran u imenskom
prostoru System.Collections.Generic. Ali, to nije jedini rječnik. Kao što ste mogli
vidjeti u prethodnim poglavljima, tip IEnumerable<T> omogućava pisanje polimorfnog
koda koji može raditi s bilo kojom sekvencijalnom klasom kolekcije. Isto možemo učiniti i s rječnikom – biblioteka klasa .NET kostura definira sučelje IDictionary<TKey,
TValue> predstavljeno u primjeru 9-8.
Primjer 9-8. IDictionary<TKey, TValue>.
namespace System.Collections.Generic
{
public interface IDictionary<TKey, TValue> :
ICollection<KeyValuePair<TKey, TValue>>,
IEnumerable<KeyValuePair<TKey, TValue>>,
IEnumerable
{
void Add(TKey key, TValue value);
bool ContainsKey(TKey key);
bool Remove(TKey key);
bool TryGetValue(TKey key, out TValue value);
TValue this[TKey key] { get; set; }
ICollection<TKey> Keys { get; }
ICollection<TValue> Values { get; }
}
}
Možete vidjeti indekser – TValue this[TKey] – i metodu TryGetValue koju smo već
upoznali. Ali, kao što možete vidjeti, rječnici implementiraju i druge korisne standardne značajke.
Metoda Add dodaje novi zapis u rječnik. To može izgledati kao ponavljanje jer nove
unose možete dodavati s indekserom, ali razlika je u tome što će indekser vrlo rado
prepisati postojeću vrijednost. Ako pozovete Add zapravo govorite da vjerujete kako
dodajete novi unos. Zbog toga će metoda izbaciti iznimku ako rječnik već sadrži vrijednost za zadani ključ.
Na raspolaganju su vam članovi koji mogu pomoći da saznate što se nalazi u nizu
– popis svih ključeva možete dobiti iz svojstava Keys i Values. Oba implementiraju
ICollection<T>, što je specijalizirana verzija IEnumerable<T> koje dodaje korisne članove kao što su Count, Contains i CopyTo.
Zamijetite da IDictionary<TKey, TValue> izvodi iz IEnumerable<KeyPairValue<TKey,
TValue>>. To znači da je moguće enumerirati kroz sadržaj rječnika pomoću petlje
foreach. Stavke KeyPairValue<TKey, TValue> vraćene iz enumeracije samo pakiraju
ključ i pridruženu vrijednost u jednu strukturu. Mogli bismo dodati metodu iz pri-
308 | Programiranje C# 4.0
mjera 9-9 klasi iz primjera 9-7 kako bi ispisali samo one elemente koji nemaju podrazumijevanu vrijednost.
Primjer 9-9. Prolaženje kroz sadržaj rječnika.
public void ShowArrayContents()
{
foreach (var item in nonEmptyValues)
{
Console.WriteLine("Key: '{0}', Value: '{1}'",
item.Key, item.Value);
}
}
Podsjetite se: IEnumerable<T> je sve što LINQ to Objects treba, pa možemo koristiti
rječnike s LINQ-om.
Rječnici i LINQ
Budući da se sve implementacije IDictionary<TKey, TValue> mogu enumerirati,
možemo na njima pokrenuti LINQ upite. Ako imamo klasu RecordCache iz primjera
9-5 možemo implementirati pravila brisanja iz cache memorije prikazana u primjeru
9-10.
Primjer 9-10. LINQ upit sa rječnikom kao izvorom.
private void DiscardAnyOldCacheEntries()
{
// Pozivanje ToList() na izvoru kako bi se upit postavio u
// kopiju enumeracije, da bi se izbjegle iznimke zbog pozivanja
// uklonite foreach petlju koja slijedi.
var staleKeys = from entry in cachedRecords.ToList()
where IsStale(entry.Value)
select entry.Key;
foreach (int staleKey in staleKeys)
{
cachedRecords.Remove(staleKey);
}
}
Pomoću LINQ upita moguće je i izraditi nove rječnike. Primjer 9-11 pokazuje kako se
koristi standardni ToDictionary LINQ operator.
Primjer 9-11. Operator ToDictionary iz LINQ-a.
IDictionary<int, string> buildingIdToNameMap =
MyDataSource.Buildings.ToDictionary(
building => building.ID,
building => building.Name);
Ovaj primjer pretpostavlja da je MyDataSource neka klasa izvora podataka koja pruža
kolekciju s popisom zgrada u koju se mogu postavljati upiti. Budući da se takvi podaci
obično čuvaju u bazama podataka, vjerojatno biste koristili LINQ pružatelja za baze
Poglavlje 9: Klase kolekcija | 309
podataka kao što je LINQ to Entities ili LINQ to SQL. Priroda izvora nema veliki
utjecaj – mehanizam za izdvajanje resursa u objekte rječnika je u svakom slučaju isti.
Operator ToDictionary treba reći kako može izdvojiti ključ iz svake stavke u sekvenci.
Ovdje smo naveli lambda izraz koji uzima svojstvo ID – ponavljamo, ovo svojstvo
vjerojatno će generirati alat za preslikavanje iz baze podataka poput alata koje nude
Entity ili LINQ to SQL. (Kasnije u knjizi ćemo obraditi tehnologije za pristupanje
podacima.) Ovaj primjer pruža i drugi lambda izraz koji bira vrijednost – ovdje smo
odabrali svojstvo Name. Drugi lambda izraz je opcionalan – ako ga ne pružite, ToDictionary će kao vrijednost upotrijebiti cijelu izvornu stavku – pa će u ovom primjeru
izostavljanje drugog izraza učiniti da ToDictionary vrati IDictionary<int, Zgrada>
(gdje je Zgrada bilo koji tip objekta koji MyDataSource.Buildings pruža).
Kôd iz primjera 9-11 daje isti rezultat kao i:
var buildingIdToNameMap = new Dictionary<int, string>();
foreach (var building in MyDataSource.Buildings)
{
buildingIdToNameMap.Add(building.ID, building.Name);
}
HashSet i SortedSet
HashSet<T> je kolekcija različitih vrijednosti. Ako istu vrijednost dodate dvaput, ona će
ignorirati drugu metodu add i spriječiti dodavanje iste vrijednosti drugi put. To možete
iskoristiti da biste osigurali jedinstvenost. Za primjer zamislite poslužitelj za čavrljanje
preko mreže (chat). Ako želite osigurati da su korisnička imena jedinstvena mogli biste
održavati HashSet<string> zauzetih korisničkih imena i provjeravati je li korisničko
ime koje je novi korisnik odabrao zauzeto pozivanjem metode Contains.
Možda ćete primijetiti da List<T> nudi metodu Contains pa uz malo
dodatnog koda možete implementirati provjeru jedinstvenosti pomoću
List<T>. Međutim, HashSet<T> koristi isto brzo pretraživanje temeljeno
na hash kodu, kao i rječnik, pa će HashSet<T> biti brža za veće skupove
nego List<T>.
HashSet<T> je dodana u verziji .NET 3.5. Prije nego što je ona objavljena programeri su
koristili rječnike sa beskorisnim vrijednostima kao način za dobivanje brze provjere
jedinstvenosti temeljene na hash kodu.
.NET 4.0 uvodi SortedSet<T>, koja je vrlo slična HashSet<T> ali dodaje značajku koja
omogućava da, ako prođete kroz stavke u skupu, one izlaze po redoslijedu. (Možete
pružiti IComparer<T> za definiranje redoslijeda ili možete koristiti tipove koji se sami
sortiraju.) Isti efekt možete postići i primjenom LINQ operatora OrderBy na HashSet<T>,
ali SortedSet<T> sortira stavke dok ih dodajete, što znači da su već sortirane u trenutku
kad započnete prolaziti kroz njih.
310 | Programiranje C# 4.0
HashSet<T> i SortedSet<T> nude razne korisne metode temeljene na skupu. Na primjer,
možete utvrditi je li IEnumerable<T> podskup (tj. nalaze li se svi njegovi elementi u)
skupa u IsSubsetOf. Dostupne metode definirane su zajedničkim sučeljem ISet<T> iz
primjera 9-12.
Primjer 9-12. ISet<T>.
namespace System.Collections.Generic
{
public interface ISet<T> : ICollection<T>, IEnumerable<T>, IEnumerable
{
bool Add(T item);
void ExceptWith(IEnumerable<T> other);
void IntersectWith(IEnumerable<T> other);
bool IsProperSubsetOf(IEnumerable<T> other);
bool IsProperSupersetOf(IEnumerable<T> other);
bool IsSubsetOf(IEnumerable<T> other);
bool IsSupersetOf(IEnumerable<T> other);
bool Overlaps(IEnumerable<T> other);
bool SetEquals(IEnumerable<T> other);
void SymmetricExceptWith(IEnumerable<T> other);
void UnionWith(IEnumerable<T> other);
}
}
Redovi
Queue<T> je zgodan tip kolekcije za obradu entiteta po načelu „prvi unutra, prvi van“.
Na primjer, neke ordinacije opće prakse funkcioniraju bez naručivanja pacijenata.
Budući da se u takvoj situaciji vrijeme potrebno za pregled razlikuje od pacijenta do
pacijenta. Zbog toga pregledavanje pacijenta za pacijentom može biti mnogo efikasnije
od dodjeljivanja jednakih vremenskih slotova svim pacijentima.
Ovakav način poslovanja možete modelirati s Queue<Patient> (gdje je Patient klasa
koju definira naša aplikacija). Kada pacijent stigne bit će dodan u red pozivanjem
metode Enqueue:
private Queue<Patient> waitingPatients = new Queue<Patient>();
...
public void AddPatientToQueue(Patient newlyArrivedPatient)
{
waitingPatients.Enqueue(newlyArrivedPatient);
}
Kada liječnik završi s jednim pacijentom i spreman je primiti drugog, metoda Dequeue
će vratiti pacijenta koji je najduže u redu i zatim ga iz njega ukloniti:
Patient nextPatientToSee = waitingPatients.Dequeue();
Poglavlje 9: Klase kolekcija | 311
Iako ovaj primjer odlično ilustrira kako Queue<T> radi, vjerojatno je
nećete koristiti u ovakvoj situaciji u stvarnosti. U ovakvoj primjeni
htjet ćete na elegantan način riješiti probleme koji mogu nastati ako se
računalo blokira ili nestane električne energije. U stvarnosti ćete najvjerojatnije popis pacijenata koji čekaju spremiti u bazu podataka zajedno
s nekakvim podatkom koji govori o njihovom položaju u redu.
Redovi u memoriji češće se sreću u višenitnim poslužiteljima kako bi se pratilo stanje
obavljenih i neobavljenih poslova. Budući da još nismo obradili rad u mreži i niti,
prerano je da pokažemo primjer na tu temu.
Queue<T> implementira IEnumerable<T> pa možete koristiti LINQ upite za stavke u
cijelom redu. Ona implementira i ICollection<T> pa možete utvrditi je li red prazan
ispitivanjem svojstva Count.
Queue<T> radi isključivo po načelu „prvi unutra, prvi van“ pa će Dequeue vraćati stavke
točno onim redoslijedom kojim su dodavane preko Enqueue. To je dobar pristup za
ordinaciju opće prakse, ali ne i za odjel hitne pomoći.
Povezani popisi
Ako ste ikada bili na odjelu hitne pomoći znat ćete da je čekanje u redu neizbježno,
osim ako ste izuzetno sretni ili izuzetno nesretni. Ako ste sretni, u čekaonici neće biti
nikoga i bit ćete odmah primljeni na obradu. Ako ste nesretni, vaše stanje je toliko loše
da zahtijeva hitnu obradu pa ćete preskočiti cijeli red.
U hitnim slučajevima primjenjuje se sustav trijaže koji određuje gdje će novi pacijent
biti smješten u redu. Sličan obrazac ponašanja susreće se i na drugim mjestima – ljudi
koji često putuju zrakoplovom i imaju zlatne članske kartice zrakoplovne tvrtke mogu
biti „ubačeni“ na let u zadnjoj minuti dok obični putnici moraju čekati satima; poznate
osobe mogu jednostavno ući u restoran i dobit će mjesto iako obični građani moraju
rezervirati mjesto tjednima unaprijed.
Klasa LinkList<T> može modelirati i takve obrasce ponašanja. U najjednostavnijem
obliku možete je koristiti kao Queue<T> – pozivati AddLast za dodavanje stavke na kraj
reda (što radi i Enqueue) i RemoveFirst za uklanjanje stavke s početka reda (što radi i
Dequeue). Ali, stavku možete dodati i na početak reda pomoću AddFirst ili je možete
dodati na bilo koje mjesto u redu pomoću metoda AddBefore i AddAfter. U primjeru
9-13 koristimo ih za dodavanje novih pacijenata u red.
Primjer 9-13. Trijaža na djelu.
private LinkedList<Patient> waitingPatients = new LinkedList<Patient>();
...
LinkedListNode<Patient> current = waitingPatients.First;
312 | Programiranje C# 4.0
while (current != null)
{
if (current.Value.AtImminentRiskOfDeath)
{
current = current.Next;
}
else
{
break;
}
}
if (current == null)
{
waitingPatients.AddLast(newPatient);
}
else
{
waitingPatients.AddBefore(current, newPatient);
}
Ovaj kôd dodaje novog pacijenta iza svih pacijenata čiji je život izravno ugrožen ali
ispred svih drugih – taj pacijent je ili prilično loše ili je jedan od donatora bolnice.
(Trijaža je u stvarnosti kompliciranija ali i dalje možete dodavati stavke na popis na
isti način, bez obzira na način na koji birate točku umetanja.)
Obratite pozornost na primjenu klase LinkedListNode<T> – na taj način LinkedList<T>
prikazuje sadržaj reda. Ona ne omogućava samo pregledavanje stavke u redu, već i
kretanje naprijed-natrag kroz red pomoću svojstava Next i Previous.
Stogovi
Dok Queue<T> funkcionira po načelu „prvi unutra, prvi van“, Stack<T> funkcionira
po načelu „zadnji unutra, prvi van“. Ako na to načelo gledate iz perspektive redanja,
izgleda kao velika nepravda – oni koji su stigli kasnije imaju prioritet nad onima koji
su stigli prvi. Ipak, ima situacija u kojima je takvo načelo potrebno.
Karakteristike performansi računala su takve da su ona sklona brže raditi sa stvarima
koje su nedavno obrađivala umjesto sa stvarima koje su obrađivala prije duže vremena. Procesori računala sadrže cache memoriju koja omogućava brži pristup podacima nego glavna memorija i u njoj se primjenjuje pravilo prema kojem je vjerojatnije
da u cache memoriji ostanu nedavno korišteni podaci a ne podaci koji se nisu duže
koristili.
Ako pišete poslužiteljsku aplikaciju možda će biti važnije da osigurate brži protok
a ne fer tretiranje – ukupna brzina kojom obrađujete podatke može biti važnija od
vremena potrebnog za obradu pojedinačnog posla. U takvim situacijama redoslijed
„zadnji unutra, prvi van“ ima više smisla – poslovi koji su tek dodani u red imaju veću
vjerojatnost da ostanu u cache memoriji procesora nego oni koji su odavno dodani
u red pa ćete postići bolje performanse u situacijama velikog opterećenja ako prvo
Poglavlje 9: Klase kolekcija | 313
budete obrađivali nove stavke. Stavke koje već dugo stoje u redu jednostavno će morati
još čekati.
Kao i Queue<T>, i Stack<T> nudi metodu za dodavanje stavke i metodu za uklanjanje
stavke. Zovu se, redom, Push i Pop i vrlo su slične metodama Enqueue i Dequeue osim
što obje rade s iste strane popisa. (Isti efekt možete postići i ako koristite LinkedList i
pozivate samo AddFirst i RemoveFirst.)
Stog može biti koristan i za upravljanje poviješću učitanih stranica. Gumb Back u Web
pregledniku također radi po načelu „zadnji unutra, prvi van“ – prva stranica koju vam
prikaže je zadnja koju ste bili posjetili. (Ako želite i gumb Forward trebate definirati
drugi stog – svaki put kad korisnik pritisne gumb Back metoda Push smjesti trenutnu
stranicu na stog gumba Forward. Ako korisnik pritisne gumb Forward, metoda Pop
poziva stranicu sa stoga Forward a Push smješta trenutnu stranicu na stog gumba
Back.)
Sažetak
Biblioteka klasa .NET kostura pruža razne korisne kolekcije klasa. U prethodnom
poglavlju vidjeli smo List<T>, koja pruža jednostavni popis stavki promjenjive dužine.
Rječnici čuvaju unose tako što ih pridružuju ključevima i omogućavaju brzo pretraživanje po njima. HashSet<T> i SortedSet<T> omogućavaju rad sa skupovima jedinstvenih
stavki uz opcionalno zadavanje redoslijeda. Redovi, povezani popisi i stogovi upravljaju redovima stavki i nude različite načine na koje se redoslijed ulaza stavki odnosi
prema redoslijedu izlaza stavki.
314 | Programiranje C# 4.0