Tutorijal: Implementacija jednostruko ulančane liste u programskom jeziku C++
Uvod
Pošto smo se upoznali sa strukturama podataka i osnovama objektno orijentisanog programiranja, prikazaćemo i implementaciju jednostruko ulančane liste.
U pitanju je linearna struktura podataka (jedna od najčešće korišćenih), sastavljena iz čvorova koji se ređaju jedan za drugim, a budući da u članku prikazujemo "školsku" implementaciju jednostruko ulančane liste, * svaki čvor će obuhvatati dva podatka:
- celobrojnu vrednost
- pokazivač na sledeći čvor
Operacije koje ćemo implementirati su sledeće:
- pronalaženje vrednosti elementa na određenom indeksu
- umetanje elementa (na proizvoljno zadatu poziciju)
- uklanjanje elementa (sa proizvoljno zadate pozicije)
- ispis liste (u proizvoljnom formatu)
Da bi sve funkcionisalo kako dolikuje, proveravaćemo - svuda gde je neophodno - da li se traženi indeks nalazi u granicama liste (a svakako ćemo voditi računa i o drugim detaljima).
Implementacija
U članku o strukturama podataka, već ste imali priliku da vidite kako izgleda (skoro potpuna) implementacija steka, ali, neke delove implementacije smo ipak izostavili (zarad bolje preglednosti).
Ovoga puta, definisaćemo sve neophodne metode i, takođe, budući da u listi (za razliku od steka), program može pokušati da pristupi elementu koji je izvan granica strukture, predvidećemo za takve situacije i odgovarajuće izuzetke.
Osnovna struktura čvora
Osnovnu strukturu čvora definisaćemo na sledeći način (već poznat od ranije):
Čvor je definisan preko sloga (struct
), a ne preko klase, što - s obzirom na jednostavnost tražene strukture čvora - smatramo racionalnim pristupom.
Osnovna struktura liste
Klasa Lista
može se definisati na sledeći način:
Da pojasnimo:
- pokazivač na prvi čvor u listi (polje
Prvi
), čuva se kao zasebno polje (pre svega zarad iteratora * koji će biti implementiran u nastavku) - pokazivač na poslednji element (polje
Poslednji
), omogućava (znatno) lakše dodavanje novih čvorova - pomoćni pokazivač na čvor kome se trenutno pristupa (polje
Trenutni
), ** koristi se u metodama za dodavanje i uklanjanje elemenata, kao i u metodi za pristup elementu preko indeksa
Pored navedenih privatnih polja, koristićemo i jedno javno polje (odeljak public
na slici #2):
- čuvaćemo kao podatak i broj elemenata liste (pre svega zarad provere granica liste, pri pretrazi elemenata preko indeksa)
Konstruktor(i)
Za potrebe kreiranja novih objekata klase Lista
, predvideli smo dva konstruktora:
1) Konstruktor za kreiranje prazne liste:
2) Konstruktor koji odmah u listu dodaje prvi element:
Konstruktore ćemo implementirati na sledeći način:
Prvi konstruktor je jasan sam po sebi, dok, kada je u pitanju drugi konstruktor (iako je situacija poznata od ranije), nikako nije zgoreg da se podsetimo na princip kreiranja novih čvorova.
Budući da su polja klase Lista
: Prvi
i Poslednji
, (samo) pokazivači na slog tipa Cvor
, potrebno je - preko funkcije malloc
- kreirati konkretan slog na koji će navedeni pokazivači pokazivati.
Sličan motiv biće (naravno) prisutan i u metodi za dodavanje elemenata, a samostalno vođenje računa o dodeli memorije novim elementima i oslobađanju memorije pri uklanjanju elemenata ("memory management"), jedna je od najbitnijih odlika programskih jezika C i C++ u opštem smislu.
Pronalaženje vrednosti na određenom indeksu - preko metode
Za pronalaženje elementa na određenom indeksu, bilo da funkcija treba da vrati vrednost (što će biti varijacija koju ćemo prikazati u nastavku), ili referencu na element (i takvu funkciju ćemo takođe prikazati, i koristiti kao pomoćnu funkciju u drugim funkcijama), potrebno je, pažljivo i dosledno:
- krenuti od prvog elementa liste *
- prelaziti na susedne elemente preko pokazivača
Sledeci
, sve dok se ne pronađe element čiji je indeks zadat.
Navedeni algoritam nije teško implementirati, uz dve bitne napomene:
- za "šetanje" kroz listu, praktično je obavezno koristiti zaseban pomoćni pokazivač, * zarad očuvanja pokazivača koji je vezan za prvi element liste
- u slučaju prekoračenja granica liste (ukoliko je traženi indeks manji od 0, ili veći od gornje granice), potrebno je reagovati na adekvatan način (u implementaciji kojom se bavimo u članku, "uhvatićemo" i obraditi izuzetak)
Kada razumemo sve preduslove, nije teško razumeti ni sledeću implementaciju:
- prvi
if
kreira izuzetak ukoliko traženi indeks nije u granicama liste - ako nije došlo do izuzetka, pomoćni pokazivač se usmerava * na prvi element liste
- obavljaju se uzastopni prelasci na 'sledeći' čvor, sve dok se ne pronađe element čiji je indeks zadat
- na kraju, ispisuje se vrednost pronađenog elementa
Preko sličnog postupka, može se pronaći i referenca na element čiji se indeks zadaje kao ulazna vrednost.
Pronalaženje reference na element sa zadatim indeksom
Ukoliko je potrebno da funkcija pronalazenjeElementa
vrati referencu na element sa zadatim indeksom (umesto brojčane vrednosti datog elementa), dovoljno je prepraviti tip funkcije i povratnu vrednost:
Metodu pronalazenjeElementa
koristićemo u metodama za dodavanje i uklanjanje elemenata.
Očitavanje vrednosti na određenom indeksu - preklapanjem operatora "[]"
Pošto smo definisali metodu za pronalaženje vrednosti elementa (koji je povezan sa određenim indeksom) - može se nadalje pristupati elementima, ali, poziv funkcije pronalazenjeVrednosti
....
.... ipak (u očima većine programera), ne deluje elegantno као sledeći kod:
Doduše, o lepoti i funkcionalnosti jednog ili drugog pristupa, moglo bi se diskutovati, budući da operator []
'priziva asocijacije' na statičke nizove, u kojima operacija pristupa elementu ima složenost O(1) - što naravno nije klasa složenosti koja odlikuje algoritam za pristup elementu u ulančanim listama (zbog čega korisnici mogu biti zbunjeni).
U svakom slučaju, preklapanje operatora []
(eng. overload), može se obaviti na sledeći način:
U definiciji metode navodi se rezervisana reč operator
i sam operator koji se povezuje sa povratnom vrednošću metode.
Pošto je operator []
"preklopljen", može se koristiti uz objekte klase Lista
(onako kako smo videli na slici #9).
Dodavanje elementa na kraj liste (push)
Pošto koristimo nazive funkcija na srpskom jeziku, moramo biti precizni i napomenuti da pod dodavanjem podrazumevamo (prosto) nadovezivanje elementa na kraj liste ("push"), a ne umetanje na proizvoljnu poziciju ("splice").
U svakom slučaju, i na ovom mestu se srećemo sa funkcijom malloc
, preko koje je potrebno kreirati novi čvor.
Razlika je samo u tome da li se lista "načinje" dodavanjem prvog elementa, ili se novi element nadovezuje na poslednji (postojeći) čvor.
Ukoliko je lista prazna, pokazivači Prvi
i Poslednji
biće usmereni na čvor koji je (upravo) kreiran (što smo već videli na slici #3).
U suprotnom (ukoliko lista nije prazna), prvo je potrebno povezati poslednji čvor sa novim čvorom - i potom proglasiti čvor koji je upravo dodat, za poslednji.
Još jednom vredi napomenuti da se pokazivač Poslednji
čuva upravo zarad operacije dodavanja, jer - u suprotnom - bilo bi potrebno proći kroz celu listu (što je tipična i, u praktičnom smislu neophodna, operacija u metodi za pronalaženje elementa, ali - pri dodavanju - prolazak kroz listu bi samo predstavljao gubljenje vremena).
Umetanje elementa (na zadati indeks)
Kada je u pitanju umetanje elemenata na određeni indeks, razlikuju se tri situacije:
- umetanje elementa na početak liste
- umetanje elementa na kraj liste
- umetanje elementa na bilo koju od preostalih pozicija
Umetanje na prvu ili poslednju poziciju je relativno jednostavan zahvat, i stoga ćemo najviše pažnje posvetiti proceduri za umetanje (novog) čvora na neku od međupozicija (postupak ćemo pojasniti preko donje slike).
Zarad ilustracije, ubacićemo novi čvor između (postojećih) čvorova 12
i 17
.
U implementaciji, novi čvor se kreira preko funkcije malloc
i povezuje sa pokazivačem C
, a pokazivač C.Sledeci
(pokazivac Sledeci
na novom čvoru), usmerava se na čvor #17 (tj. "sledeći" čvor).
Nakon pripremnih radnji, potrebno je pristupiti čvoru 12
(preko polja Trenutni
), i preusmeriti pokazivač Sledeci
na čvoru 12
: sa čvora 17
- na čvor 15
(novi čvor), preko naredbe: Trenutni->Sledeci = C;
(plava strelica).
Novi čvor usmeren je na čvor 17
(tj. "sledeći" čvor), a čvor 12
je (pre)usmeren na novi čvor - što praktično znači da je novi čvor umetnut u listu:
Implementacija podrazumeva (i) proveru indeksa (u smislu: 'da li indeks je u granicama liste'), nakon čega sledi traženje pozicije za novi element (uz uvažavanje pravila koja su navedena na početku odeljka):
Osvrnimo se na najvažnije detalje implementacije:
- ukoliko je traženi indeks van granica liste, metoda će vratiti izuzetak
- ukoliko pokušavamo da "umetnemo" element na poslednju poziciju, posao se jednostavno prepušta metodi za dodavanje (koja će uredno usmeriti pokazivač
Poslednji
na novi element) - umetanje čvora na prvu poziciju podrazumeva: kreiranje novog čvora (koji će biti referenciran preko pomoćnog pokazivača C), usmeravanje pokazivača
C->Sledeci
na prvi čvor u listi (slikovito: čvor koji je "još uvek" prvi u listi) i, na kraju, usmeravanje pokazivačaPrvi
, na novi čvor:this->Prvi = C;
- umetanje čvora na međupozicije obavlja se preko procedure koju smo prethodno objasnili preko slika
Uklanjanje elementa (na zadatom indeksu)
Kada je u pitanju uklanjanje elemenata, takođe se razlikuju tri situacije:
- uklanjanje prvog elementa (u kom slučaju se mora pravilno * dereferencirati pokazivač
Prvi
) - uklanjanje poslednjeg elementa (u kom slučaju se mora pravilno * dereferencirati pokazivač
Poslednji
) - uklanjanje elementa koji nije prvi ili poslednji
Definisaćemo prvo metodu za uklanjanje prvog elementa liste:
Metoda za uklanjanje prvog elementa
U metodi prepoznajemo sledeće korake:
- pokazivač
C
(pomoćni pokazivač), usmerava se na prvi element liste - pokazivač
Prvi
usmerava se na drugi element liste (koji će posle pozivanja metodeUklanjanjePrvi
, biti na početku liste) - dosadašnji prvi element liste (čvor na koji pokazuje pokazivač
C
), na kraju se uklanja
Metoda za uklanjanje poslednjeg elementa
Metodu za uklanjanje poslednjeg elementa, definisaćemo na sledeći način:
U metodi za uklanjanje poslednjeg elementa, prepoznajemo sledeće korake:
- preko čvora
C
(pomoćni pokazivač), referencira se poslednji element liste - preko pokazivača
Trenutni
, pronalazi se pretposlednji element liste, nakon čega se adresa pretposlednjeg elementa predaje pokazivačuPoslednji
- pokazivač
Sledeci
na pretposlednjem čvoru se dereferencira (postavlja na vrednostNULL
) - dosadašnji poslednji čvor (koji je referenciran preko pokazivača
C
), na kraju se uklanja
Opšta metoda za uklanjanje elementa
Opšta procedura za uklanjanje čvora, ima četiri odeljka:
if
grananje koje proverava da li je indeks unutar granica liste- poziv metode
UklanjanjePrvi
, ukoliko je indeks 0 - poziv metode
UklanjanjePoslednji
, ukoliko indeks ukazuje na poslednji element liste (indeksBrojElemenata - 1
) - ostatak metode zadužen je za uklanjanje čvorova na "međupozicijama":
Uklanjanje čvorova na međupozicijama, ilustrovaćemo preko sledeće slike:
- čvor koji se uklanja (za primer ćemo uzeti čvor #15), referencira se preko pomoćnog pokazivača
C
- preko pokazivača
C
(tačnije,C->Sledeci
), takođe je ustanovljena i referenca na čvor17
, odnosno, referenca na "sledeći" čvor (koja je još bitnija za dalji tok izvršavanja programa) - preko pokazivača
Trenutni
pristupa se čvoru12
(to jest, čvoru koji prethodi čvoru koji se uklanja) - pokazivač
Trenutni->Sledeci
usmerava se naC->Sledeci
(što je praktično čvor17
, ili, u opštem smislu, "sledeći" čvor)
Pošto smo se pobrinuli oko svih referenci (to jest, pošto su čvorovi 12
i 17
uspešno povezani), može se ukloniti čvor 15
(odnosno, preko pokazivača C
, može se osloboditi memorija koju čvor 15
zauzima).
Dopunjena metoda za uklanjanje čvora ima sledeći oblik:
Verujemo da je u ovom slučaju lakše razumeti sam kod, u odnosu na šemu koju smo prethodno videli (slike #18 i #19).
Ispis liste u proizvoljnom formatu
Ispis liste može se shvatiti kao kombinacija prolaska kroz listu i ispisivanja pojedinačnih vrednosti.
Za pretvaranje celobrojne vrednosti u nisku, koristićemo specijalizovanu metodu iz 'domaće radinosti' (koju ćemo smestiti u private
odeljak klase):
Sada se može definisati i metoda za ispis elemenata liste:
Ukoliko je lista prazna, biće ispisana prikladna poruka, dok će u suprotnom elementi liste biti ispisani redom.
Metoda za ispis svakako se može dopuniti opcijama za formatiranje (tako da se mogu birati i drugi znakovi za razdvajanje elemenata, to jest, tako da se umesto zareza može koristiti i razmak, prelazak u novi red i sl), ali, to ostavljamo vama - za vežbu. :)
Nekoliko reči o izuzecima
Zarad upoznavanja sa izuzecima, uzećemo za primer situaciju sa kojom smo se susreli u toku implementacije liste: kako treba postupiti kada, preko funkcije pronalazenjeVrednosti
, program pokuša da pristupi indeksu koji je: ili negativan broj, ili predstavlja vrednost koja prevazilazi gornju granicu liste?!
Na početku metode za pronalaženje vrednosti čvora, stoji if
grananje preko koga se proverava da li je uneti indeks unutar granica liste (što omogućava da se lako zaustavi svaki pokušaj pristupa elementima koji ne zadovoljavaju uslov), međutim, šta tačno treba da vrati metoda (čiji je povratni tip int
) - ukoliko program pokuša da pristupi elementu čiji je indeks van granica liste?
Metoda, teoretski, može vratiti (recimo) INT_MIN
, najmanju negativnu celobrojnu vrednost koja se da zabeležiti - što se može shvatiti kao "signal" da je nešto "krenulo naopako", ali, takav pristup stvara određene probleme:
- iako nije preterano verovatno u većini situacija, vrednost
INT_MIN
može biti vrednost nekog od čvorova - ceo pristup (najprostije rečeno) - nije nimalo elegantan
Rešenje je da metoda za pronalaženje vrednosti čvora - ukoliko dođe do greške - uopšte ne vrati celobrojnu vrednost, već - izuzetak * - što podrazumeva da se pozivajućoj funkciji vraća niska ili objekat koji se mogu "uhvatiti" (preko posebnog mehanizma u programskom jeziku), i prikazati.
Kada kažemo da izuzeci zahtevaju poseban tretman, mislimo na to, da - bez obzira na to da li metoda vraća izuzetak ili ne - ne možemo samo napisati ....
.... jer, ako je vrednost i
izvan granica liste, program će "pući" na donekle nepredvidljiv način.
Ako se sledeći izuzetak definiše unutar metode za pristup elementu preko indeksa ....
.... pri pozivu metode, potrebno je koristiti kod po sledećem obrascu:
- ako se ne desi ništa nepredviđeno, metoda neće vratiti izuzetak (praktično, korisnik programa neće imati predstavu da je u izvršavanje uključen mehanizam provere)
- u suprotnom, čim se u nekoj instrukciji unutar odeljka
try
pojavi bilo kakva greška, prekida se izvršavanje i prelazi se na odeljakcatch
(preko koga se "uhvaćeni" izuzetak može iskoristiti za ispis poruke korisniku, ili, za neki drugi oblik debugging-a (u gornjem primeru, samo ispisujemo poruku))
U opštem smislu, izuzeci pružaju brojne mogućnosti za "hvatanje" poruka o greškama, i stoga (kao što smo već naveli), ovoj zanimljivoj temi ćemo uskoro posvetiti mnogo više pažnje.
Za kraj, osvrnućemo se na osnovne principe za korektno navođenje komentara u programskom kodu.
Ukratko o vođenju dokumentacije
Ako se određena struktura podataka (ili bilo kakav drugi kod), kreira sa idejom o 'javnoj dostupnosti' takvog koda, pravila dobrog programiranja nalažu da kod treba da bude propraćen detaljnom dokumentacijom (ili makar osnovnim uputstvima).
Ako programski kod neće koristi drugi programeri (to jest, ako ćemo ga koristi 'mi sami'), može delovati "privlačno" da se 'odreknemo' pisanja komentara (jer - "znamo valjda šta smo pisali u kodu"), međutim - na osnovu ličnog i kolektivnog iskustva - i dalje preporučujemo pisanje dokumentacije (tj. komentara), za sve delove koda iole većeg obima i značaja (bez obzira na to 'ko će na kraju koristiti kod').
Kada kažemo "delovi koda iole većeg obima i značaja", mislimo na to da pravilno vođenje dokumentacije svakako podleže pravilima koja se tiču i toga šta ne treba pisati.
Na primer, ukoliko funkcija namenjena sabiranju brojeva, doslovno nosi naziv sabiranje
i sadrži nekoliko veoma nedvosmislenih naredbi, komentari nalik sledećim komentarima: "ovo je funkcija za sabiranje brojeva" i "ovo je naredba za računanje zbira" .... nisu baš od prevelike koristi.
Pogledajmo nekoliko primera neadekvatne upotrebe komentara:
Najjednostavnije: ukoliko je zaista očigledno čemu služi određeni deo koda, dodavanje 'suvišnih' komentara, ne samo da ne povećava čitljivost koda, već (naprotiv), može se reći da se čitljivost - umanjuje.
Kako onda (nasuprot prethodnim primerima), izgleda adekvatno prokomentarisan programski kod?
Iako šira diskusija o pravilnom vođenju dokumentacije tek prestoji u nekom kasnijem trenutku (u pitanju je tematika koja zaslužuje zaseban članak), ukratko ćemo navesti da pravilno komentarisanje koda podrazumeva (pre svega): blok komentare ispred funkcija, linijske komentare koji označavaju odeljke koda u funkcijama (zarad lakšeg prepoznavanja), * i naravno, napomene na mestima na kojima su potrebna dodatna pojašnjenja (odnosno, na mestima na kojima može 'doći do zabune').
Pogledajmo nekoliko primera:
- Školski primer (komentari kakvi se pojavljuju u udžbenicima, na sajtovima koji se bave programiranjem i sl):
- Praktičan primer:
Kratak rezime ....
Na samom kraju, ostaje samo da vam preporučimo da pokušate, da samostalno implementirate ulančanu listu (ponovo), ili (zašto da ne), neku drugu strukturu podataka.
Prvi 'kandidat' bi mogla biti dvostruko ulančana lista (lista čiji čvorovi imaju i pokazivač na prethodni element), a svakako preporučujemo i samostalnu implementaciju reda za čekanje.
Sledeća tvrdnja može vam delovati čudno, ali, duboko smo uvereni (na osnovu iskustva), da je samostalna izrada klasa preko kojih se implementiraju liste, redovi, stekovi i sl (naravno, ponovo, pod uslovom da neko ko se u taj poduhvat upušta, ipak ima dovoljno iskustva i predznanja) - mnogo lakši i jednostavniji poduhvat - od pukog čitanja tekstova o implementaciji.
Možda niste još uvek u stanju da poverujete u navedene tvrdnje, ali, bar probajte da implementirate sami nešto od prikazanog i predloženog - možete se prijatno iznenaditi. :)