Abstraktit tietotyypit

Abstraktilla tietotyypillä (abstract data type, ADT) tarkoitetaan tietotyyppiä, joka määritellään tyypin mahdollisten arvojen ja näitä käsittelevien operaatioiden avulla. Oleellista on tyypin käyttäjän näkökulma: Käyttäjä pystyy käyttämään tyyppiä tarjottujen operaatioiden avulla. Käyttäjän ei siis tarvitse tietää, miten tyyppi ja sen operaatiot on määritelty.

Huomautus

Abstraktia tietotyyppiä ei pidä sekoittaa abstraktiin tyyppiin tai abstraktiin luokkaan, joita ei käsitellä tällä kurssilla.

Esimerkkejä abstrakteista tietotyypeistä

Esimerkiksi int-tyyppiä voidaan ajatella abstraktina tietotyyppinä. Ohjelmoija voi käyttää sitä esimerkiksi normaalien aritmeettisten operaatioiden avulla (+, -, *, /) välittämättä siitä, miten int-tyyppi on toteutettu. (Mahdollisia toteutustapoja voisivat olla yhden komplementti, kahden komplementti ja BCD-koodi (binary coded decimal), mutta näistä ei olla kiinnostuneita tällä kurssilla.)

Mahdollisten operaatioiden pitää olla järkeviä kyseiselle tyypille. Esimerkiksi liikennevalotyypin mahdollisia arvoja voisivat olla punainen, keltainen ja vihreä. Tyyppi voitaisiin toteuttaa luettelotyyppinä (enum), jolloin arvot vastaavat kokonaislukuja 0, 1 ja 2. Kuitenkaan yhteenlasku värien välillä ei olisi järkevä operaatio tälle tyypille, mutta valon vaihtuminen toiseksi olisi.

Aikaisemmin olemme toteuttaneet pinoa muistuttavan korttipinon. Pinoa voidaan pitää abstraktina tietotyyppinä, jonka arvoina ovat pinoon talletettavat alkiot ja operaatioina push (alkion lisääminen pinon päällimmäiseksi) ja pop (päällimmäisen alkion poistaminen). Käytännössä tarvitaan myös operaatiot tyhjän pinon luomiseen ja tyhjyyden testaamiseen.

Edellä mainittu korttipino toteutettiin linkitettynä listana, mutta siitähän pinon käyttäjän ei tarvitse välittää. Pino voitaisiin toteuttaa myös taulukkona tai vektorina, mutta silloinkin operaatioiden push ja pop julkinen rajapinta pysyisi ennallaan.

Tiedon piilotus ja kapselointi

Abstraktit tietotyypit liittyvät läheisesti modulaarisuuteen, sillä abstraktit tietotyypit voidaan toteuttaa moduulien tai luokkien avulla. Nämä mahdollistavat julkisen rajapinnan määrittelyn, jonka avulla käyttäjä pääsee käyttämään abstraktia tietotyyppiä.

Abstraktin tietotyypin toteutus piilotetaan rajapinnan taakse, eikä käyttäjän tarvitse välittää siitä. Abstrakteihin tietotyyppeihin liittyykin läheisesti kaksi termiä: tiedon piilotus ja kapselointi.

Tiedon piilotuksella (information hiding) tarkoitetaan yleensä juuri toteutuksen yksityiskohtien piilottamista tai niihin pääsyn rajoittamista. Julkinen rajapinta paljastaa operaation nimen sekä kommentin, jossa on kerrottu, mitä operaatio tekee. Kommentissa kuvataan myös parametrit ja paluuarvo sekä niiden tarkoitus. Tämän pitäisi riittää käyttäjälle. Näin on tehty myös kirjastofunktioiden kohdalla. Käyttäjä pystyy käyttämään kirjastofunktioita tuntematta niiden tarkkaa toteutusta.

Tiedon piilottaminen voi koskea myös mitä tahansa funktioita. Jos funktiolla on kuvaava nimi, sen voi ajatella kätkevän taakseen jonkin tietyn toiminnallisuuden. Esimerkiksi jos funktion nimi on print, kutsujalle riittää tietää nimi ja tarvittavat parametrit.

Kapselointi (encapsulation) taas tarkoittaa sitä, että data ja sitä käsittelevät operaatiot sidotaan tai paketoidaan yhteen. Näinhän juuri tehtiin edellä kuvatuissa esimerkeissä abstrakteista tietotyypeistä. Esimerkiksi pinototeutuksessa meillä on pinoluokka (jota voidaan käyttää tyypin tavoin) ja tämän luokan julkiset metodit. Luokka (samoin kuin moduuli) mahdollistaa siis kapseloinnin.

Abstraktit tietotyypit, modulaarisuus, kapselointi ja tiedon piilotus liittyvät siis hyvin läheisesti yhteen. Erona on oikeastaan näkökulma, mistä asiaa tarkastellaan.

Murtolukuesimerkki

Tämän kierroksen esimerkeistä löytyy hakemisto examples/10/fraction, joka sisältää murtolukuluokan Fraction.

Murtolukuluokka on esimerkki abstraktista tietotyypistä. Samalla se on esimerkki operaattoreiden kuormittamisesta.

Murtolukuluokalla on kaksi attribuuttia: osoittaja (numerator_) ja nimittäjä (denominator_). Sillä on kuormitetut operaattorit yhtäsuuruusvertailua, yhteenlaskua ja kertolaskua varten.

Kun yhteenlasku- ja kertolaskuoperaattorit kuormitetaan, niitä voidaan käyttää samoin kuin vastaavia operaattoreita käytetään primitiivisille tyypeille. Esimerkiksi:

Fraction frac1(2, 3); // luodaan murtoluku 2/3
Fraction frac2(3, 4); // luodaan murtoluku 3/4
Fraction frac3 = frac1 + frac2;

Toki olisi mahdollista käyttää operaattorifunktiota normaalin funktion tavoin:

frac3 = frac1.operator+(frac2);

Yllä olevasta kutsutavasta huomataan, että frac3 vastaa funktion paluuarvoa, frac1 sitä oliota, johon kutsu kohdistuu, ja frac2 parametria. Samoin tästä paljastuu, että yhteenlaskun ensimmäisen operandin tulee olla olio eikä murtolukuliteraali. Operaattoreiden kuormittaminen voitaisiin tehdä myös ilman tällaista rajoitetta, mutta tämä riittää tässä tapauksessa.

Huomaa, että kuormitettuja operaattoreita voidaan yhdistellä tavanomaiseen tapaan, eli samassa lausekkeessa voi olla useita yhteenlasku- ja kertolaskuoperaattoreita.

Myös tulostusoperaattori << on kuormitettu. Tulostusoperaattorin kuormittamisella on se etu, että myös ohjelmoijan määrittelemien abstraktien tietotyyppien ilmentymiä pystytään tulostamaan samaan tapaan kuin muita tulostettavia arvoja. Esimerkiksi:

Fraction frac(2, 3); // luodaan murtoluku 2/3
cout << "Murtoluvun frac arvo on " << frac << endl;

Operaattorin << kuormittaminen tapahtuu eri tavalla kuin laskutoimitusten kuormittaminen. Operaattori << on luokan ostream metodi (ja cout on tämän luokan ilmentymä). Kohdeoliona ei siis nyt olekaan Fraction-luokan olio.

Jotta yllä oleva, tulostamiselle luonteva käyttötapa olisi mahdollista, kuormitettu tulostusfunktio kannattaa toteuttaa itsenäisenä funktiona, joka ei ole minkään luokan metodi. Tulostusfunktiossa joudutaan kuitenkin käyttämään Fraction-luokan yksityisten attribuuttien numerator_ ja denominator_ arvoja. Tätä varten tarvitaan metodit getNumerator ja getDenominator.

Tulostusfunktion ensimmäisenä parametrina on tulostuvirta ja toisena ohjelmoijan määrittelemän luokan ilmentymä eli tässä tapauksessa luokan Fraction ilmentymä. Huomaa myös, että tulostusfunktio palauttaa parametrina saamansa tulostusvirran. Tämä mahdollistaa operaattorin << ketjuttamisen, mikä näkyy edellisessä koodin pätkässä.

Vastaavalla myös lukuoperaattori >> voitaisiin kuormittaa. Näin ole esimerkissä kuitenkaan tehty, vaan murtolukujen lukeminen tapahtuu pääohjelmassa. Syynä tähän on se, että on haluttu tarkistaa, ettei nimittäjäksi anneta nollaa.

Murtolukuluokalla on myös kaksi yksityistä metodia: gcd ja reduce. Metodi reduce muuttaa murtoluvun mahdollisimman supistettuun muotoon. Tätä tarvitaan esimerkiksi yhtäsuuruusvertailussa. Samoin yhteen- ja kertolaskun tulos annetaan mahdollisimman supistetussa muodossa.

Supistaminen tapahtuu niin, että lasketaan osoittajan ja nimittäjän suurin yhteinen tekijä (greatest common divisor, gcd), ja molemmat jaetaan tällä tekijällä. Tässä tarvitaan metodia gcd, joka on toteutettu rekursiivisesti.

Kaiken kaikkiaan esimerkki näyttää, miten ohjelmoijan itsensä kirjoittamaa luokkaa ja tämän luokan olioita voidaan käyttää hyvin samaan tapaan kuin kielen valmiita primitiivisiä tyyppejä.

Toteutettu Fraction-luokka ei kuitenkaan ole täydellinen. Kun yhteenlaskuoperaattori on kuormitettu, jolloin +-symbolia voi käyttää tavalliseen tapaan, luokan käyttäjä voi helposti kuvitella, että myös operaattoria += voisi käyttää kuten yleensä. Näin ei kuitenkaan ole, sillä kyseistä operaattoria ei ole kuormitettu. Ylimääräisenä harjoitustehtävänä voit toteuttaa kyseisen operaattorifunktion.