Luku 3 Muuttujat, arvot, oliot ja tyypit Tässä luvussa tarkastellaan ohjelmointikielten perusrakenteita sikäli kun ne koskevat datan säilyttämistä. Tämän luvun peruskäsitteitä ovat muuttujat, arvot, oliot ja tyypit. Tässä luvussa sanaa olio käytetään laajassa merkityksessä emme nyt puhu olio-ohjelmoinnin olioista, ellei toisin mainita. Luvussa tarkastellaan asioita paitsi epämuodollisesti myös denotationaalisen merkitysopin 1 näkökulmasta. 3.1 Arvot Arvot ovat abstrakteja, matemaattisia käsitteitä. Voidaan ajatella, että kaikki yhdellä ohjelmointikielellä ilmaistavissa olevat arvot on koottu yhteen joukkoon, Value. Tähän joukkoon sisältyy monta erillistä joukkoa, esimerkiksi kokonaislukujen joukko Int, merkkien joukko Char ja kaikkien mahdollisten arvojen jonojen joukko Seq: Value = Int + Char + Seq + Varsinaisesti se, mistä kaikista joukoista Value on muodostettu, riippuu tarkasteltavasta ohjelmointikielestä. Näillä kaikilla joukoilla on omien arvojensa lisäksi yhteinen, erityinen huono alkio, jota merkitään ja lausutaan pohja (bottom). Sitä käytetään merkitsemään epäonnistuneen tai päättymättömän laskennan (kuvitteellista) tulosta. 1. Lloyd Allison. A practical introduction to denotational semantics, Cambridge Computer Science Texts 23, Cambridge University Press, 1986. Katso myös R. D. Tennentin artikkelia The Denotational Semantics of Programming Languages, Communications of the ACM, vol. 19, no. 8, August 1976. 25
26 LUKU 3. MUUTTUJAT, ARVOT, OLIOT JA TYYPIT Koska arvot ovat abstrakteja käsitteitä, ne eivät sijaitse esimerkiksi tietokoneen jossakin muistipaikassa. Arvoilla ei itse asiassa ole sijaintipaikkaa lainkaan (vaikka jotkut filosofit saattaisivatkin olla eri mieltä:-). Samoin arvot ovat ajattomia: ne eivät synny eivätkä kuole. Arvon lukumäärääkään ei voida mielekkäästi laskea (kuinka monta ykköstä on?). Arvot eivät myöskään muutu (oho, ykkönen onkin nyt kakkonen vai onko?). Arvoja ei pidä sekoittaa niitä ohjelmatekstissä edustaviin merkintöihin. Näiden välillä on kuitenkin yhteys, jota mallitetaan funktiona V : Literal Value (Literal on kaikkien niiden merkkijonojen joukko, jotka ovat literaaleja ja siten edustavat jotain arvoa; tämä joukko voidaan määritellä esimerkiksi BNF:ää käyttäen). Esimerkiksi merkintä 42 edustaa (denotes) ohjelmatekstissä arvoa 42: V 42 = 42. 2 3.2 Oliot 3.2.1 Olio muistialueena Eri tietokoneet tallentavat tietoa eri tavoin. Yhteistä kaikille yleisesti käytetyille tietokoneille on se, että koneen muisti on jaettu muistipaikkoihin, joilla on osoite ja jotka sijaitsevat muistissa peräkkäin (tosin muistin ei tarvitse olla yhtenäinen). Osoite esitetään tavallisesti kokonaislukuna. Kukin muistipaikka tallentaa yhden tavun. Tavulla (byte) tarkoitetaan tietynmittaista bittijonoa, joka riittää yhden merkin esittämiseen. Nykyisin sillä tarkoitetaan myös tietokoneen pienintä osoitettavissa olevaa muistiyksikköä, joka on enintään konesanan (machine word) kokoinen. Aiemmin on käytössä ollut 6-, 7- ja 9-bittisiä tavuja. Nykyään 8- bittinen tavu on yleisin, mutta 64-bittiset tavut eivät ole täysin tavattomia. Mikäli tarkoitetaan nimenomaan 8-bittistä tavua, tulisi puhua oktetista (octet). Ohjelmointikielen näkökulmasta tietokoneen muisti jakautuu olioihin (objects) ja vapaaseen muistiin. Kuhunkin olioon liittyy kolme ominaisuutta: osoite, tyyppi ja arvo. Olion arvo on kahden ensimmäisen ominaisuuden sekä tietokoneen muistin tilan funktio: olion osoitteesta koneen muistissa alkava olion koon (joka selviää olion tyypistä) pituinen tavujono tulkitaan tyypin mukaisesti olion arvoksi. Olioilla on myös identiteetti. Se ei näy ohjelmointikielen tasolla, vaan se on ohjelmistoanalyysin ja -suunnittelun väline, ja samoin sitä käytetään ohjelmointikielen tutkiskelussa. Identiteetti on puhdas samuuden abstraktio: jotta jokin 2. Sulkeita käytetään tarkasteltavan kielen abstraktien konstruktioiden ympärillä erottamaan niitä tarkastelukielen (denotationaalinen merkitysoppi) konstruktioista.
3.2. OLIOT 27 olion ominaisuus olisi olion identiteetti, pitäisi seuraavien kolmen väitteiden pitää sille paikkaansa: 1. Jokaisella oliolla on identiteetti. 2. Eri olioilla on eri identiteetti. 3. Oliolla on koko olemassaolonsa aikana sama identiteetti. Identiteettien joukkoa merkitään tässä Idty. Se, mikä olion ominaisuus identiteetti varsinaisesti on, ei ole tässä oleellista oikean vastauksen keksiminen on hyvin vaikeaa. Esimerkiksi olion osoite tuntuu houkuttelevalta vaihtoehdolta, mutta se ei käy, koska olion osoite voi aivan hyvin vaihtua ohjelman suorituksen aikana. 3.2.2 Elinikä Toisin kuin arvot, oliot sijaitsevat ajassa. Oliolla on tietty syntyhetki ja tietty kuolinhetki; puhutaan sen elinajasta (lifetime). Olio voi 1. syntyä ohjelman alkaessa ja kuolla sen loppuessa (staattinen olio, static object), 2. syntyä tiettyyn ohjelmalohkoon tultaessa ja kuolla sieltä poistuttaessa (pinodynaaminen olio, stack-dynamic object), 3. syntyä erityisen luontioperaation ja kuolla erityisen tuhoamisoperaation vaikutuksesta (manuaalisesti tuhottava kekodynaaminen olio, manually deallocated heap-dynamic object), 4. syntyä erityisen luontioperaation vaikutuksesta ja kuolla joskus sitten, kun sitä ei enää kaivata (automaattisesti kuoleva kekodynaaminen olio, automatically deallocated heap-dynamic object), ja 5. syntyä jo ennen ohjelman alkamista tai kuolla vasta joskus ohjelman päättymisen jälkeen (säilyvä olio, persistent object). Kaikki ohjelmointikielet eivät tue kaikkia edellä mainittuja elinikätyyppejä. Esimerkiksi alkuperäisessä Fortranissa käytettiin vain staattisia olioita. Useimmat kielet eivät tue säilyviä olioita. Monet laajassa käytössä olevat kielet eivät tue automaattisesti kuolevia kekodynaamisia olioita, mutta lähes kaikki muut kielet puolestaan eivät tue manuaalisesti tuhottavia kekodynaamisia olioita. Staattiset oliot Staattiset oliot syntyvät ohjelman suorituksen alkaessa ja kuolevat sen päättyessä. Käännettyjen ohjelmien ohjelmatiedostoissa näillä olioilla on oma paikkansa ohjelmakoodin rinnalla, ja näiden olioitten alkuarvo on usein ilmaistu jo ohjelmatiedostossa. Nämä oliot syntyvät ohjelman alkaessa siten, että ohjelmakoodi näine olioineen ladataan muistiin. Ne kuolevat, kun ohjelman ohjelmakoodi näine olioineen poistetaan muistista ohjelman suorituksen päätyttyä.
28 LUKU 3. MUUTTUJAT, ARVOT, OLIOT JA TYYPIT Staattisten olioiden käyttäminen ajon aikana on yleensä tehokasta: niiden muistiosoite voidaan laskea linkitysvaiheessa, jolloin olioon viittaaminen voi usein tapahtua suoralla osoituksella (direct addressing). Pinodynaamiset oliot Lähes kaikki ohjelmointikielten toteutukset varaavat ajonaikaisesta muistista alueen käytettäväksi pinona. Lähes kaikki prosessorit tukevat tätä varaamalla erityisen rekisterin (esimerkiksi IA32:ssa SP) pino-osoitinkäyttöön (osoittamaan ensimmäistä varaamatonta muistipaikkaa pinossa). Pinodynaamiset oliot ovat sellaisia otuksia, jotka luodaan tarvittaessa (esimerkiksi tiettyyn lohkoon tultaessa) ja jotka tuhotaan käänteisessä luontijärjestyksessä (esimerkiksi kyseisestä lohkosta poistuttaessa). Kielissä, joissa voidaan käsitellä osoittimia olioihin vapaasti, pinodynaamisiin olioihin liittyy ongelma: ne saattavat kuollessaan jättää jälkeensä orpoja osoittimia (dangling pointers). Mikäli tällaisia osoittimia käytetään, astutaan hyvin määritellyn toiminnallisuuden ulkopuolelle. (Aiemmin käsiteltyjen Hoaren kriteereiden mukaan ohjelmointikielen pitäisi suojata tätä vastaan.) Yksi klassinen esimerkki orpojen osoittimien ongelmasta kärsivästä kielestä on C. Esimerkiksi seuraava aliohjelma palauttaa orvon osoittimen: char const * readline(void) { char line[512]; fgets(line, sizeof line / sizeof *line, stdin); return line; } Kekodynaamiset oliot Kekomuisti (heap memory) on muistialue, johon voi synnyttää ja tappaa kaikenkokoisia olioita milloin vain. Kekomuistista varattavien olioiden elinaika on periaatteessa rajattu vain ohjelman suorituksen alulla ja lopulla. Tällaisten, kekodynaamisten olioiden merkitys ohjelmoinnissa on yhä suurempi: lähes kaikki vähänkään monimutkaisemmat tietorakenteet vaativat käytännössä kekodynaamisten olioiden käyttöä. Huomaa, ettei kekodynaamisten olioiden käyttämiseen välttämättä tarvita tukea eksplisiittisille osoittimille. Kekodynaamiset oliot jaetaan kahteen alaluokkaan sen perusteella, pitääkö ohjelmoijan huolehtia niiden tuhoamisesta itse vai hoitaako sen kielen toteutuksen ajonaikainen osa (runtime environment). Edellisiä voitaneen sanoa manuaalisesti tuhottaviksi, jälkimmäisiä automaattisesti kuoleviksi.
3.2. OLIOT 29 Manuaalisesti tuhottavissa kekodynaamisissa olioissa on sama ongelma kuin pinodynaamisissa olioissa, mikäli osoittimet sallitaan: orpoja osoittimia syntyy aivan liian helposti. Automaattisesti kuolevat oliot toteutetaan muistinsiivousmenetelmillä 3 (garbage collection methods). Perusalgoritmeja on kolme: viitelaskuritekniikka, merkkaa ja lakaise -tekniikka sekä pysäytä ja kopioi -tekniikka. Seuraavassa esitellään nämä kolme perusalgoritmia sekä niiden vaatimat ajonaikaiset tietorakenteet. Viitelaskuri Viitelaskuritekniikan (reference counting) perusidea on ylläpitää kussakin oliossa tietoa sen osoitteen kopioiden (viitteiden) lukumäärästä (ns. viitelaskuri). Laskuri alustetaan nollaksi. Joka kerta, kun sen osoite kopioidaan, kasvatetaan viitelaskuria yhdellä. Joka kerta, kun yksi kopio osoitteesta hävitetään, viitelaskuria vähennetään yhdellä, ja jos se menee nollaksi, olion sisältämät osoittimet nollataan (päivittäen rekursiivisesti vittattujen olioiden viitelaskureita) ja olio tapetaan. Viitelaskuritekniikan etuna on se, että tuhoamiset tapahtuvat täysin synkronoidusti: heti, kun viimeinen viite katoaa, oliokin tapetaan. Tämä mahdollistaa tuhoamishetken koukutuksen: ohjelmoija voi kirjoittaa aliohjelman, joka ajetaan, kun olio kuolee. Toisaalta, jos kyseinen olio on esimerkiksi ison puurakenteen juuri, joudutaan koko puurakenne tuhoamaan samalla kertaa, ja ohjelma pysähtyy joksikin aikaa. Jos kaksi oliota viittaavat toisiinsa, on kummankin viitelaskuri aina positiivinen. Eli jos viimeinenkin ulkopuolinen viite näihin olioihin katoaa, oliot jäävät edelleen elämään. Näin viitelaskuritekniikassa on pieni riski hallitsemattomaan muistivuotoon. Merkkaa ja lakaise Merkkaa ja lakaise (mark and sweep) on vanhin siivousalgoritmi ja edelleen varsin käyttökelpoinen. Sen perusidea on varata kustakin oliosta yksi bitti muistinhallinnan käyttöön. Tämä bitti on kaikissa olioissa normaalisti samanarvoinen (joko kaikilla päällä tai kaikilla pois). Kun muisti loppuu tai siivous joudutaan jostain muusta syystä aloittamaan, siivoin (collector) merkkaa (vaihtaa tuon bitin arvon toiseksi) kaikki staattiset ja pinodynaamiset 3. Antti-Juhani Kaijanaho. Muistinhallinta siivousmenetelmien avulla, tietotekniikan (ohjelmistotekniikka) LuK-tutkielma, Jyväskylän yliopisto, tietotekniikan laitos, 5.10.2001. Katso myös Richard Jonesin ja Rafael Linsin kirjaa Garbage Collection: Algorithms for Automatic Dynamic Memory Management, Chichester, Wiley, 1996.
30 LUKU 3. MUUTTUJAT, ARVOT, OLIOT JA TYYPIT oliot niiden sanotaan muodostavan juurijoukon (root set). Sitten se tekee saman kaikille niille olioille, joiden osoite on tallennettu johonkin jo merkattuun olioon. Kun kaikki merkattavat oliot on merkattu, siivoin käy koko muistin läpi ja tappaa ne oliot, jotka eivät tulleet merkatuksi (toisin sanoen ne oliot, jotka eivät kuulu juurijoukon transitiiviseen sulkeumaan). Tämä menetelmä kykenee poistamaan kaikki tarpeettomaksi käyneet oliot kunhan osoitteet muistetaan nollata, kun niitä ei enää tarvita. Menetelmän haittapuolena on se, ettei edellä mainittujen, kuoleman yhteydessä ajettavien aliohjelmien kirjoittaminen ole erityisen mielekästä. Tämä metodi on tavallisesti maailman pysäyttävää tyyppiä kun siivous on käynnissä, kaikki muu laskenta on pysähdyksissä. Tämä aiheuttaa ongelmia monilla sovellusalueilla, joten tästä teemasta on kehitetty erityisiä vähittäisiä (incremental) muunnelmia, joissa tyypillisesti tehdään aina hieman lisää siivousta, kun muistia varataan. Pysäytä ja kopioi Pysäytä ja kopioi (stop and copy) -menetelmän perusideana on jakaa muisti kahteen yhtäsuureen alueeseen, puoliavaruuteen (semispace). Jompi kumpi niistä on aina lähdeavaruus (fromspace), toista sanotaan vastaavasti kohdeavaruudeksi (tospace). Uusille olioille varataan tila aina kohdeavaruudesta (jossa kaikki elossa olevat oliot sijaitsevat peräkkäin) pinovarauksen tyyliin. Jos varaus epäonnistuu, aloitetaan siivous. Tällöin ensin vaihdetaan puoliavaruuksien merkitykset: lähdeavaruudesta tehdään kohdeavaruus ja kohdeavaruudesta lähdeavaruus. Kaikki juurijoukkoon kuuluvien (eli staattisten ja pinodynaamisten) olioiden sisältämien osoitteiden päässä olevat oliot kopioidaan lähdeavaruudesta kohdeavaruuteen (ja korjataan nuo osoitteet osoittamaan kohdeavaruuteen). Kopioitujen olioitten paikalle laitetaan edelleenohjausosoite (forwarding pointer) eli osoite, joka osoittaa olion uuteen kopioon. Sitten lähdetään kulkemaan kohdeavaruutta olioittain ja kopioidaan lähdeavaruudesta kohdeavaruuteen kaikki ne oliot, joiden osoitteisiin näin törmätään ja joita ei ole vielä kopioitu. Joka tapauksessa korjataan kaikki osoitteet osoittamaan kohdeavaruuteen. Lopulta kaikki on kopioitu, ja voidaan taas jatkaa muuta työtä. Pysäytä ja kopioi -menetelmällä on samat perusedut ja -viat kuin merkkaa ja lakaise -menetelmällä. Erojakin tosin on. Koska kaikki olioiden luonti voidaan tehdä pinodynaamiseen tapaan, on se nopeaa. Kopiointi estää muistin pirstoutumisen (fragmentation). Jos ohjelma varaa paljon kekodynaamisia olioita, joista suurinta osaa tarvitaan hyvin lyhyen aikaa, on pysäytä ja kopioi ehdottomasti tehokkain tapa toteuttaa tämä (tehokkaampi jopa kuin pinodynaamisten olioiden käyttö).
3.2. OLIOT 31 Myös tästä menetelmästä on olemassa muunnelmia, jotka pyrkivät vähentämään yksittäisen pysähdyksen pituutta. Nämä ovat niinsanottuja ikäperustaisia (generational) menetelmiä, joissa muisti jaetaan useampaan puoliavaruuspariin. Yksi niistä on lastentarha, johon uudet oliot synnytetään. Se siivotaan usein, koska useimmat oliot kuolevat nuorina. Pitkäikäisimmät lapset ylennetään samalla seuraavaan puoliavaruuteen (sukupolveen), jota siivotaan harvemmin. Samaan tapaan ylennetään siitäkin pitkäikäisimmät seuraavaan sukupolveen, kunnes kaikki puoliavaruusparit on käyty läpi. Merkille pantavaa näissä menetelmissä on se, että olion osoite voi muuttua sen elinaikana. Tosin algoritmit pitävät kyllä huolen siitä, että kaikki viitteet säilyvät ehjinä siirrosoperaation yli, eli kaikki osoitteet päivitetään osoittamaan olion uutta paikkaa. Ajonaikaiset tietorakenteet Toimiakseen siivousalgoritmit tarvitsevat ajonaikaista tukea. Ensinnäkin kaikki osoitteet on kyettävä tunnistamaan luotettavasti eroon kokonaisluvuista. Dynaamisesti tyypitettyjen ja tyypittömien kielten toteutuksissa tämä on tapana hoitaa laputuksella: varataan merkityksettömin bitti (bitti 0) lapuksi, joka on nolla luvuilla ja yksi osoittimilla. Useissa järjestelmissä osoitteet ovat aina vähintään kahdella jaollisia (ja joissakin järjestelmissä parittomat pyöristetään alaspäin parillisiksi), jolloin tuo pieni virhe osoitteessa ei haittaa yhtään mitään. Aritmetiikka luvuilla puolestaan onnistuu lähes ilman muutoksia, kun alin bitti on nolla (lukuarvo saadaan siirtämällä bittejä yksi oikealle). Staattisesti tyypitettyjen kielten toteutukset jättävät monesti siivoimen käyttöön staattisia muuttujia, jotka kuvailevat kaikkien tyyppien rakenteen, erityisesti sen, missä kohtaa oliota osoittimet sijaitsevat; jotkut jopa räätälöivät siivoimen osia (esimerkiksi merkkaa ja lakaise -siivoimen merkkausosan) kullekin tyypille erikseen, jolloin mitään ajonaikaista tietoa ei tarvita. On myös mahdollista kirjoittaa siivoin, joka toimii ns. vihamielisessä ympäristössä. Boehmin ja Weiserin siivoin 4 on tästä hyvä esimerkki: se toimii C- ja C++-kielten siivoimena ilman mitään tukea kääntäjältä. Erityisesti se ei tiedä, mitkä osoittimelta näyttävät otukset ovat osoittimia ja mitkä eivät. Se tekee ns. konservatiivisuusoletuksen: kaikki osoittimelta näyttävät ovat osoittimia. Tämä toimii, koska siivoin ei siirtele olioita ympäriinsä. Yleensä oletuksesta seuraava muistivuotokin on hyvin vähäistä. 4. http://www.hpl.hp.com/personal/hans_boehm/gc/