Sormet syvemmälle C:hen

ITKA203 Käyttöjärjestelmät -kurssin Demo 4 keväällä 2018 ja 2019 ja 2020 ja 2021 ja 2022. "Lähespikaintro C-kielellä ohjelmointiin"

Paavo Nieminen, paavo.j.nieminen@jyu.fi

Jyväskylän yliopiston Informaatioteknologian tiedekunta.

STATUS: Vuosina 2018-2022 tämä on tarkoitettu ennakkolukemistoksi ja esitehtäväksi noin 2 tunnin mittaiseen yhteisharjoitteeseen, joka tehdään mikroluokassa (vuosina 2020-2021 oli poikkeuksellisesti Zoom-järjestelmän kautta etäyhteydellä, mutta 2022 palataan lähikeskusteluun - tule paikalle vain terveenä). Vaihtoehtona on itsenäinen, hieman laajempi tehtävä. Deadline-perusteisesti suorittavien opiskelijoiden tulisi tutustua tähän tarkoin ja hyvissä ajoin ennen omaa sessiota aikavälillä 25.-27.4.2021, jotta sessiosta tulee mielekäs. Ennakkoilmoittautuminen Sisussa on toivottavaa, että osataan varautua. Samanlaiset sessiot tarjotaan 2 tunnin välein ajankohtina, joissa Sisussa on aikaikkunoita nimikkeellä "Laboratoriotyö". Jos yksi ei tuntunut itselle riittävältä, saa jäädä halutessaan kertaamaan saman setin toisen porukan kanssa.

Sisällys

Mistä tässä harjoitteessa on kyse

Tämä harjoite perustuu demoissa 1-3 esiteltyihin perustaitoihin, jotka oletetaan tunnetuksi ja joita harjoitellaan lisää samalla kun tehdään uutta. Teksti on pieni suomenkielinen selviytymisopas C-kieleen sillä tasolla, jota käyttöjärjestelmäkurssin loppuosan seuraaminen vaatii. Se yrittää kattaa C-kielestä kaiken tarvittavan tämän kurssin sisällön seuraamiseksi. Ilmoita, jos jotakin tarpeellista tuntuu puuttuvan. Mallia on katsottu esimerkiksi seuraavista maailmalta löytyvistä tutoriaaleista, joita asiasta kiinnostuneet voivat lisäksi selata omatoimisesti omalla ajallaan (linkkien toimivuus tarkistettu viimeksi 20.4.2022):

Vaikka tämä yrittää olla minimaalinen, on luettavaa, kokeiltavaa ja hahmotettavaa aika paljon. Pakollista palautettavaa on loppujen lopuksi hyvin vähän. Edelleen tekemisen ja oppimisen määrä on jokaisen omalla vastuulla. Pyri käyttämään kurssiin tarkoitettu viikkotuntimäärä tehokkaasti.. Tätä voi kertailla myös myöhemmin, kun kurssin loppupuoliskossa katsotaan käyttöjärjestelmän toimintojen käyttöä pienten C-koodiesimerkkien kautta ja todellisia toteutusratkaisuja Linux-lähdekoodia silmäilemällä.

Aiempien havaintojen perusteella rakenteisen ohjelmoinnin edellyttämä imperatiivisten algoritmien kehittelykyky ei monellakaan ole vielä ehtinyt hahmottua asiaan johdattelevan Ohjelmointi 1 -kurssin aikana. Tämä on luonnollista, koska ohjelmoinnin oppiminen on pitkä ja ikuisesti jatkuva prosessi. On siis otettava huomioon, että tämän materiaalin läpikäynti voi kestää opiskelijasta riippuen tunnista (läpiluku, asian toteaminen jo aiemmin opituksi) useisiin päiviin (ohjelmoinnin perusasioiden kertaaminen, käsitteiden oppiminen, kattavamman oppikirjallisuuden etsiminen ja lukeminen, avun kysymyminen ohjaustilaisuuksissa, vertaistukikanavalla tai kavereiden kanssa tmv.).

Hyvänä puolena toivottavasti Käyttöjärjestelmät -sisällön lopputulema tältä osin olisi myös opiskelijoiden ohjelmointitaidollisten tasoerojen pieneneminen ennen tätä seuraavia jatkokursseja. Vähintään kaikki saavat mahdollisuuden nähdä ohjelman rakentamisen periaatteet ruohonjuuritasolla, käyttöjärjestelmän ja laiteläheisen kielen päällä.

Harjoituksen tavoitteita:

  • Osaat siirtää aiemmin oppimiasi ohjelmoinnin perusrakenteita olioperustaisesta lohkorakenteisesta kielestä (esimerkiksi C# / Java) C-kieleen, erityisesti:

    • muuttujat ja niiden tyypit
    • ehtolauseet
    • silmukat eli toistorakenteet
    • aliohjelmakutsu (vastaa jotakuinkin luokkametodin kutsua; instanssimetodeja ei C:ssä olekaan olemassa).
  • Tiedät, mitä ovat taulukot C-kielessä.

  • Tiedät, mitä ovat merkkijonot C-kielessä.

  • Tiedät, mitä ovat järjestetyt datakokoelmat eli "tietueet" eli "struktuurit" eli "oliomaiset rakenteet" C-kielessä.

  • Tiedät, mitä ovat osoittimet ja miten niitä käytetään.

  • Ymmärrät näkökulmaeron: C on puhtaasti imperatiivinen, rakenteinen kieli ja lisäksi laitteistoläheinen, eli toisin sanoen:

    • Olioita ei samassa mielessä ole kuin C#:ssa, Javassa tai C++:ssa (siis ei instanssimetodeja, ei perintää, ei sisäisen tilan piilotusta, ei rajapintoja samassa mielessä, ei poikkeuksia, ei roskienkeruuta, eikä muitakaan elämää helpottavia abstraktioita... ellet tee tai ota käyttöön aliohjelmakirjastoa, johon olisi toteutettu näitä hienouksia).
    • Datakokoelmien rakenteet ja niitä käsittelevät aliohjelmat ovat erillisiä kokonaisuuksia eivätkä olioluokiksi paketoituja.
  • Ymmärrät erot ja samankaltaisuudet aliohjelmakutsussa (luokkametodi / staattinen metodi) ja instanssimetodikutsussa (normaali olioinstanssin tilaa hyödyntävä metodi)

Keväällä 2015 määriteltyjen osaamistavoitteiden osalta tämän ja aiemmat demot tehtyään opiskelija erityisesti:

Rajaus 1: Tavoitteena on, että tämän kurssin nykyiset esimerkkiohjelmat olisivat ISO-standardin C99 mukaista C-kieltä. Jos huomaat poikkeamia standardiin nähden, ilmoita niistä. Kieli on C eikä siis missään tapauksessa olio-ohjelmointiin soveltuva C++, joka on erillinen, paljon laajempi, kieli. Käytämme C-kielen vanhaa standardia C99, koska uusin POSIX (tilanne vuonna 2022) on yhä kiinnitetty siihen. Olettaa voisi, että koska C:stä on jo olemassa uudempia standardeja, tulevaisuudessa myös POSIX siirtyy viittaamaan johonkin uudempaan standardiin. Uusin C-kieli vuonna 2022 on C17 ja todennäköisesti sen jälkeen tulee aikanaan C23. Standardointiprosessi yleensä pyrkii laajentamaan, ei kaventamaan, määritelmiä, joten C99:llä tehtyjen ohjelmien pääsääntöisesti pitäisi toimia muuttamattomina myös uudempien kääntäjien kanssa. Sen sijaan vuoden 1999 jälkeen kehitettyjä uusia kieli- tai kirjasto-ominaisuuksia ei periaatteessa saisi pedantisti C99:n mukaiseksi tehdyssä ohjelmassa käyttää, vaikka ne niin näppäriä olisivatkin!

Rajaus 2: Kaikkia ominaisuuksia C-kielestä ei voida käydä läpi näin lyhyessä prujussa. Oikea ymmärrys vaatii C:tä varten tehdyn oppikirjan lukemista ja muutaman oikean ohjelman tekemistä C:llä. Sen sijaan tässä koetetaan vain nopealla hands-on -kokemuksella saada hahmottumaan, miten lyhyitä ja irrallisia C-koodin pätkiä ymmärretään tai tehdään. Kautta linjan C-ohjelmoinnin rooli kurssilla on demonstroida tietokonelaitteiston ja käyttöjärjestelmän toimintaperiaatteita konkreettisella tasolla. Esitieto-oletus on olio-ohjelmoinnin perustaito esimerkiksi C# tai Java-kielellä, joihin "oliotonta ohjelmointia" ja C-kieltä tässä kautta linjan vertaillaan.

Rajaus 3: Tässä vedetään pari mutkaa suoraksi todelliseen C-kielen määrittelyyn nähden. Kuten muissakin kurssin asioissa, on syytä muistaa, että tämä on johdantotyyppistä kurssimateriaalia, jossa jätetään asioita sanomatta yksinkertaisuuden nimissä. Oikea totuus löytyy spesifikaatioista - tämän demon osalta C99 -standardista ja POSIXista. Draft-versio C99:n speksistä on saatavissa tuolta: http://www.open-std.org/jtc1/sc22/WG14/www/docs/n1256.pdf (linkin toimivuus tarkistettu viimeksi 20.4.2022)

Pari juttua:

  • Jos ohjelmaan tuli ikuinen silmukka tai muuta jumia, pystyt luultavasti lopettamaan sen painamalla Ctrl-c; katso ettei jää ikuisia silmukoita turhaan pyörimään monen käyttäjän palvelinkoneelle. Kurssilla käsitellään hyvin pian käyttöjärjestelmän palvelu "signaalit", johon mm. Ctrl-c -painallus liittyy.
  • Päätavoite on ymmärtäminen, joten tähtää siihen ja käytä tarvittava aika ja energia. Erillisiä, omia muistiinpanoja voi olla hyvä tehdä edelleenkin.

Seuraavaksi syvennytään joihinkin yksityiskohtiin, jotka ovat osin samanlaisia ja osin erilaisia C-kielessä kuin nykyiseltä Ohjelmointi 1 -kurssilta tutussa oliokielessä.

C-kielisen ohjelman yleisrakenne, kääntäminen ja linkittäminen

Ennen kuin päästään käsiksi itse C-kieleen, on katsottava aihetta kauempaa, eli tyypillisen C-kielisen ohjelman ilmenemisestä kokoelmana lähdekooditiedostoja sekä vaiheita, joiden kautta C-kielisestä ohjelmasta saadaan rakennettua (engl. build / make) suoritettava ohjelmatiedosto (engl. executable, "binary"). Nyt tässäkin avataan kuorikomentojen avulla konepelti asioista, joita tyypillinen ohjelmakehitysympäristö eli IDE tekee. POSIX määrittelee yhteensopivan käyttöjärjestelmän vapaaehtoisena lisäosiona yksinkertaisen tekstipohjaisen "ohjelmakehitysympäristön". Vastaavat vaiheet tapahtuvat napin painalluksella IDE:ssä, mutta ohjelman tekijän yleissivistykseen kuuluu tietää, mitä IDE oikeastaan tekee. Ilman tätä tietoa toimintaan kuuluisi "mystisiä vaiheita", joissa ilmenevien virheiden jäljittäminen olisi mystiikan takia vaikeaa. Otetaan nyt pois kaikki se mystisyys tekemällä käännöskomennot sellaisina kuin ne IDEn napistakin voisivat tulla!

Lähdekoodien ja moduulien organisointi

C-ohjelman perusyksikkö on yksittäinen lähdekooditiedosto (source file), jonka nimen on sovittu päättyvän merkkeihin .c eli piste ja pieni cee-kirjain. Toinen nimi tälle on käännösyksikkö (compilation unit). Lähdekooditiedostossa voi olla yksi tai useampia aliohjelmia tai pelkkää vakiodataa. Ei ole mitään yleistä standardia ohjaamassa, mitä yhteen tiedostoon laitetaan: Siellä voi olla vaikkapa kokonainen 100 000 rivin ohjelma kaikkine määrityksineen ja aliohjelmineen, tai siellä voi olla yksi 30 rivin aliohjelma, tai siellä voi olla vain rimpsu merkkijonoja, joita halutaan käyttää datana. Suosituksena voi maalaisjärjellä ajatella, että yhdessä tiedostossa saisi mieluiten olla hallittavissa oleva määrä yhteen tiettyyn toimintokokonaisuuteen liittyviä asioita. Jos tiedosto alkaa jossain vaiheessa kasvaa liian isoksi, se voi olla hyvä jakaa jollain järkiperusteella pienempiin osiin.

Yleensä C-ohjelmissa on myös .h-päätteisiksi sovittuja otsikkotiedostoja (header file). Otsikkotiedostoissa julkaistaan tietorakenteiden määrittelyt ja sovelluksen yhteiseen käyttöön tarkoitettujen, .c-tiedostoissa toteutettujen, aliohjelmien kutsurajapinnat (eli kunkin aliohjelman "otsikkorivi"). Otsikkotiedostoihin laitetaan siis C-kielellä kirjoitettujen ohjelmamoduulien tai kirjastojen julkiset rajapinnat, joten ne ovat paljolti samassa roolissa kuin uudemmissa oliokielissä luokkakirjastojen julkiset luokat julkisine metodeineen. Otsikkotiedostoistakaan ei ole standardia ohjaamassa, mitä yhteen tiedostoon tulee suhteessa .c -päätteisiin lähdekoodeihin. Maalaisjärjellä ajatellen voisi olla fiksua, että esimerkiksi yhtä .c -lähdekooditiedostoa vastaisi samalla alkuosalla nimetty .h -tiedosto, jossa julkaistaan julkiseksi tarkoitetut tyypit ja aliohjelmat juuri kyseisestä .c -koodista. Tarpeen mukaan joko .c tai .h -tiedostot voi olla järkevää nimetä eri tavoin tai jakaa hienojakoisemmin kuin vastinkappaleensa.

Laajemman sovelluksen C-lähdekoodi koostuu yhdestä tai useammasta .c -ohjelmasta ja .h -otsikkotiedostosta.

C:ssä ei ole mitään kiinnitettyä hakemistorakennetta ohjelman osioille, kunhan kääntäjä tietää, mistä sen pitää etsiä .h ja .c -tiedostoja. Maalaisjärjellä ajatellen ohjelma voi olla fiksua jakaa alihakemistoihin sitten, kun sen tarvittavien lähdekoodi- ja otsikkotiedostojen määrä kasvaa niin isoksi, että kokonaisuutta on vaikea hallita yhdessä hakemistossa, tai jos ryhmittely alihakemistoihin on muusta syystä tarpeen esimerkiksi toiminnallisten kokonaisuuksien suhteen. Esimerkiksi Linuxin lähdekoodin miljoonat koodirivit jakautuvat käyttöjärjestelmän tyypillisten tehtäväkokonaisuuksien mukaisiin alihakemistoihin (muistinhallinta omassaan, tiedostojen hallinta omassaan, laiteajurit omassaan ja niin edelleen).

Valmiiden kirjastojen otsikkotiedostot kuten stdlib.h ovat usein hakemistossa /usr/include (voit huvikseen listata hakemiston jalavassa tai halavassa, tietenkin). Tiedostoissa on tosiaan vain otsikot eli aliohjelmien ja tietorakenteiden esittelyt sekä erinäisten vakioiden määrittelyjä. Varsinaiset toteutukset eli valmiiksi käännetyt aliohjelmakirjastot ovat usein hakemistossa nimeltä /lib (peruskirjastot) ja /usr/lib (tarpeen mukaan kyseiseen tietokoneyksilöön asennetut lisäkirjastot sovellusohjelmien kääntämistä ja ajamista varten). 64-bittisessä esimerkkijärjestelmässämme vastaavat hakemistot ovat /lib64 ja /usr/lib64. Kääntäjä voidaan ohjata etsimään kirjastoja ja otsikoita muualtakin kuin oletuspaikoista antamalla sille tietyt komentoriviargumentit, jotka selviävät kääntäjän manuaalista; sijainteja ei ole sinänsä mitenkään ennalta sovittu.

C-ohjelman sijoittelussa tiedostoihin ja hakemistoihin on siis aika rajaton vapaus. Ohjelmoijalla on kaikki vastuu siitä, että rakenne on selkeä: hyvä ja ylläpidettävä koodi luultavasti on sellainen, joka on jaettu toiminnallisuuden mukaisesti järkevänkokoisiin, samankaltaisia asioita tekeviä tai käsitteleviä aliohjelmia sisältäviin .c-tiedostoihin ja vastaavasti .h-tiedostoihin, jotka julkaisevat .c-tiedostojen rajapinnat muiden moduulien käytettäväksi.

Esimerkiksi Linux-käyttöjärjestelmän ydin https://www.kernel.org/ on käyttökelpoisuudellaan jonkinlaista tunnettuutta kerännyt C-ohjelmisto, jossa on useita miljoonia lähdekoodirivejä. Sen organisoinnista ja muista käytänteistä mallia ottamalla ei välttämättä mene kovin pahasti pieleen, ainakaan jos tarkoitus on ylläpitää ja kehittää usean miljoonan rivin ohjelmistoa useiden satojen löyhästi toisensa tuntevien ohjelmoijien yhteisönä...

Ohjelman luonti: moduulien erillinen kääntö, yhdistäminen linkittämällä

C-ohjelmien lähdekoodi on siis .c ja .h -päätteisiä tekstitiedostoja. C-koodin suorittaminen taas on pääasiassa sitä, kun aliohjelmat kutsuvat toisia aliohjelmia jossakin järjestyksessä. Voivat ne kutsua itseäänkin, jos tarvitaan rekursiivinen (eli itseään kutsuva) algoritmi. Ajettavassa ohjelmassa on löydettävä jokainen tarvittava aliohjelma, joita voi olla monessa paikassa:

  • käännettävässä sovelluksessa itsessään, mahdollisesti eri lähdekooditiedostoissa ja -hakemistoissa
  • C:n standardiapukirjastoissa (libc, libm, jne.)
  • käyttöjärjestelmän yhteensopivuuskirjastoissa, joiden olemassaolon esimerkiksi POSIX määrää (esimerkiksi säikeiden luontiin tarkoitettu libpthread.so)
  • sovelluskohtaisissa apukirjastoissa (esimerkiksi laajat grafiikka-, ääni-, pelimoottori- tai matematiikkakirjastot kuten libGL.so, libSDL2.so, libsatlas.so, ...).

Isot, monia lähdekooditiedostoja sisältävät, C-ohjelmat käännetään ensiksi erillisesti kohde- eli objektitiedostoiksi, joiden pääte on .o tai .obj. Kussakin objektitiedostossa on vastaavassa lähdekooditiedostossa (eli .c -tiedostossa) olleiden aliohjelmien käännetyt konekielikoodit sekä symbolit (symbol) eli aliohjelmien selväkieliset nimet, joiden kautta konekielikoodien sijainti löytyy. Toki objektitiedostoissa voi olla mukana myös metatietoja kuten käännöspäivämäärä, kääntäjän versiotiedot sekä debuggaustietoa kuten alkuperäisen lähdekoodin tiedostonimiä ja rivinumeroita. Linuxissa objektitiedoston muoto on niin sanotun ELF-formaatin mukainen (Executable and Linkable Format).

Toiminnallisesti samaan asiaan liittyviä objektitiedostoja voidaan yhdistää toisiinsa kirjastoiksi (unix-maailmassa archive eli .a -tiedostot), joissa on siis kaikissa mukaan otetuissa objektitiedostoissa olevien aliohjelmien konekielikoodi. Apukirjastot ovat yleensä tällaisina .a -kirjastoina valmiina liitettäväksi niitä käyttäviin sovelluksiin.

Lopulliseen suoritettavaan ohjelmatiedostoon täytyy luonnollisesti yhdistellä eli linkittää (link) kaikki erillisissä objektitiedostoissa ja kirjastoissa sijaitsevat aliohjelmat ja datat. Tämä ohjelman rakentamisen viimeinen vaihe tehdään käännösvaiheen linkkerillä (engl. linker tai link editor), joka on tätä varten tehty työkaluohjelma. Täsmälleen yhden objektitiedoston pitää sisältää sovitunlainen main() -aliohjelma, että C-ohjelman sisäänmenopiste (entry point) määrittyy yksikäsitteisesti. Tällä tavoin käännetty ja linkitetty lopullinen tiedosto sisältää siis main()-aliohjelman ja mahdollisesti paljon muiden aliohjelmien koodia. Ohjelmaa kutsutaan staattisesti linkitetyksi, mikäli se toimii sellaisenaan ilman lisäkirjastoja. Kaikki tarvittavat objektit on siinä silloin mukana.

Olisi kuitenkin hyvä, jos apukirjaston kaikkia aliohjelmia ei tarvitsisi laittaa mukaan jokaisen niitä käyttävän ohjelman käännökseen. Onhan tilanhukkaa, jos vaikka megatavun kokoinen aliohjelmakirjasto laitetaan kymmeneen eri sovellukseen samanlaisena. Tätä varten useimmiten tehdään staattisen sijasta dynaaminen linkitys (dynamic linking). Varsinkin laajat sovelluskohtaiset apukirjastot (kuten grafiikkakoneistot ym.) kannattaa kääntää ns. paikkariippumattomaksi koodiksi (engl. position independent code) tai ainakin uudelleensijoiteltavaksi (engl. relocatable), jolloin kirjaston koodi ei sisällä "kovakoodattuja" muistiosoitteita, vaan luottaa käyttöjärjestelmän ohjelmalataukseen tai ajon aikana toimivaan dynaamiseen linkkeriin kaikkien sisäisten muistiosoitteidensa suhteen.

Tällainen, useimmiten apukirjastoksi tarkoitettu koodi voidaan tallentaa ja toimittaa käyttäjän tietokoneelle jaettuna objektina (shared object, .so) eli dynaamisesti linkitettävänä kirjastona (dynamically linked library, .DLL). Paikkariippumaton kirjasto voidaan linkittää dynaamisesti sitä tarvitsevan ohjelman virtuaalimuistiavaruuteen: Kirjastoa käyttävän ohjelman käynnistyksen yhteydessä käyttöjärjestelmän linkitysjärjestelmä etsii kirjaston tiedostojärjestelmästä ja liittää sen konekielikoodin käynnistyvän ohjelman muistiavaruuteen. Käännösvaiheen linkitysohjelma on kirjannut ylös tarvittavan kirjaston tiedostonimen sekä symbolit (aliohjelmien ja datapätkien selväkieliset nimet), joita kyseisestä kirjastotiedostosta tarvitaan. Jos käyttäjän tietokoneelle ei ole asennettu jotakin ohjelmatiedostoon täten kirjattua dynaamisesti linkitettävää kirjastoa, ohjelman käynnistäminen ei tietenkään onnistu.

Etukäteen ei voi tietää, mihin muistiosoitteisiin kirjasto kartoitetaan, joten ajonaikaisen linkkerin täytyy sumplia aliohjelmakutsujen ja vakiodatan muistiviitteet kohdilleen symbolien perusteella. Osoitteiden selvitys on mahdollista tehdä joko ohjelman käynnistyessä tai "laiskasti" ohjelman suorituksen aikana, silloin kun jotakin kirjaston aliohjelmaa tarvitaan ensimmäistä kertaa. Ohjelmalla on mahdollisuus pyytää käyttöjärjestelmältä kirjaston linkittämistä myös ajon aikana, siis jopa kysyä käyttäjältä interaktiivisesti, minkä kirjastoversion hän haluaa liitettävän suoritettavan ohjelman osaksi.

Realistisia käyttötarpeita ajonaikaiselle linkittämiselle voisivat olla konekielisenä jaettuna objektina / DLL:nä toimitetut musiikki- tai grafiikkaohjelmistojen plugin-liitännäiset eli "plugarit", joilla tehdään mitä moninaisimpia erikoisefektejä, mutta kaikkia sataa erilaista ei kannata ladata muistiin ennen kuin tiedetään, mitä muutamaa käyttäjä tällä kertaa tarvitsee... Näin ohjelma käynnistyy alussa paljon nopeammin, kun ei tarvitse siinä vaiheessa vielä ladata yhtään plugaria.

Dynaamisesti linkitettävät kirjastot ovat Windows-maailmassa nimeltään dynamically linked library, .DLL ja Unix-maailmassa shared object, .so.

Dynaamisesta linkittämisestä on etua ainakin seuraavin tavoin:

  • Levytilaa kuluu vähemmän, kun useiden ohjelmien tarvitsema dynaaminen kirjasto voidaan tallentaa vain yhdessä paikassa.
  • Useat ohjelmat voivat käyttää samaa fyysisessä keskusmuistissa olevaa kirjastoa, koska niiden sijainti kunkin prosessin virtuaalimuistialueella voi olla mikä tahansa. Näin ohjelmien lataamiseen kuluva aika ja keskusmuistin kokonaistarve pienenevät.
  • Ohjelmista saadaan modulaarisia -- esimerkiksi jos rajapinta pysyy samana, bugikorjaukset tai parannukset ohjelmiin voidaan tehdä päivittämällä vain yksittäinen kirjasto (joka on pienempi kuin kokonaissofta).

Esimerkiksi Javan virtuaalikoneen suorituksessa tavallaan ohjelmat aina "linkittyvät dynaamisesti", koska luokkia (jotka tavallaan vastaavat C:n objektitiedostoja) ladataan muistiin laiskasti tarpeen mukaan. Jos ohjelma ei tarvitse joitain luokkia, niitä ei välttämättä ladata missään vaiheessa. Liitännäisten puuttumisen tai väärän asentumisen saattaakin huomata vasta, kun kesken ohjelman suoritusta tulee poikkeus ClassNotFoundException.

Esimerkiksi jalavan /usr/lib64/ -hakemisto (alihakemistoineen) sisältää melkoisen paljon kirjastoja, joita sovellusohjelmiin voisi linkittää dynaamisesti (".so") tai staattisesti (".a"). Saman voi todeta omalla kotitietokoneellaan paikantamalla Windowsinsa DLL:t, Unix-tyyppisen käyttöjärjestelmänsä .so:t tai Mac-käyttöjärjestelmänsä .dylib:it.

Esikäännös: ylimääräinen makrokieli

Ikävä kyllä tämä seuraava kohtalaisen järkyttävä asia on käytävä läpi, ennen kuin C-koodin lukemiseen tulee mitään järkeä.

Reunahuomautus: Miksi tämä on järkyttävää?

Opiskelijan aikoinaan esittämä relevantti kysymys: Miksi tämä on "järkyttävää", kun kuitenkin esikääntäjän avulla tehdään hyödyllisiä asioita?

Vastauksesta voidaan varmasti olla montaa mieltä. C-kieli tehtiin 1970-luvulla, jolloin esikääntäjä oli varmasti hyvin perusteltu ja toimiva ratkaisu välttämättömiin tehtäviin. 2000-luvun näkökulmasta kummalliselta tuntuu, että on tehty "kieli kielen sisälle", jolloin lähdekoodin semantiikka voi olla erilainen kuin lähdekoodista voi ensikatsomalta päätellä. Esimerkiksi kirjastoja käyttäessä ei voi olla varma, mitä aliohjelmaa (jos mitään) kutsutaan kohdassa, jossa on kirjoitettu aliohjelmakutsun näköinen asia. Onko se makro vai aliohjelma? Ei auta kuin tarkistaa, jos asialla on väliä... Uudemmissa kielissä tehdään ihan samoja asioita, joihin C:n esikääntäjä on tarkoitettu, mutta ominaisuudet ovat mukana kielessä itsessään eikä erillisessä, toisessa kielessä. Välissä onkin ehtinyt olla yli 40 vuotta aikaa miettiä porukalla, miten ohjelmointikieliä kannattaa tai ei olisi kannattanut tehdä...

Järkyttävyydestä voidaan käydä, ja käydään, kuumaa keskustelua. Seuraavat linkit EIVÄT ole tämän kurssin asiaa, mutta halutessasi katso esimerkiksi http://www.boost.org/doc/libs/1_60_0/libs/preprocessor/doc/topics/problems.html (missä sanotaan mm. "As a rule of thumb, if you can find a clean and manageable way to do something without the preprocessor, then you should do it that way") sekä esimerkkikoodi, jossa esikääntäjää on käytelty viimeisen päälle luovasti https://www.ioccc.org/2013/cable3/cable3.c (linkkien toimivuus tarkistettu viimeksi 20.4.2022)

Jälkimmäinen koodilinkki kannattaa joka tapauksessa ottaa hetkeksi tarkasteluun. Järkyttävää ei ole se, mitä esikääntäjällä kannattaa tehdä ja mihin tarkoituksiin se on alunperin tarkoitettu vaan se, mitä sillä voi saada aikaan pahimmillaan ja kauneimmillaan. Moista helvetinkonetta ei välttämättä soisi olevan olemassa. Mutta jos se on olemassa, eikä ole menossa mihinkään ehkä ikinä, niin kannattaa meidän silläkin leikkiä ja kilpailla, jotta on iltapuhteiksi tekemistä :). Eikä siinä mittään.. tuo koodihan kääntyy esimerkiksi Linuxilla ja toteuttaa 8086 -prosessorin emulaattorin, jossa voi pyörittää 1980-luvun lentosimulaattoria, taulukkolaskentaa ja 3D-suunnitteluohjelmistoa. Mutta lähdekoodista ei juurikaan selvää saa.

Opiskelijan havainto: Linkitetyn kilpailukoodin kääntäminen saattaa vaatia, että lähdekoodista korvataan isoilla kirjoitettu "KB" pienillä kirjaimilla "kb". Tarkkaan kun katsoo, niin todellakaan "KB" ei esiinny kuin yhdessä kohtaa koodia. Tämä on siis todennäköisesti todellakin virhe alkuperäisen tekijän taholta. Jossain määrin raflaavaa on se, että alkuperäinen koodi ylipäätään kääntyy jossain ympäristössä (mukaanlukien tämän tekstin kirjoittajan oma GNU/Linux-järjestelmä).

Loppupäätelmä reunahuomautuksesta: Pitäydytään käytännön tekemisessä perusohjeessa, että käytetään esikääntäjää vain niihin tarkoituksiin, mihin ei pystytä järkevästi varsinaisen C-lähdekoodin puolelta, okei?

Asiaan.

C-käännös ja esikääntäjän rooli

Ennen kuin kääntäjä alkaa tehdä konekieltä C-lähdekoodista, se syöttää lähdekoodin esikääntäjän (pre-compiler) läpi. Tämä on aikansa jäänne, jonka kanssa joudumme elämään. Melkein kaikissa C-ohjelmissa käytetään esikääntäjän ominaisuuksia.

Vastaavia toiminnallisuuksia on myöhempiinkin kieliin kyllä rakennettu sisälle tavalla tai toisella, mutta C:ssä täytyy ymmärtää, että esikäännösvaihe on erillinen ja se muokkaa lähdekoodia jo ennen kuin varsinainen C-kielen käännös edes tapahtuu.

Koko C-ohjelman tuottamisen ruljanssi on karkeasti ottaen seuraavanlainen:

  • Kaikille .c -tiedostoille:

    • alkuperäinen C-lähdekoodi viedään esikääntäjän läpi
    • esikäännöksen tuottama uusi, mahdollisesti jo erilainen C-lähdekoodi viedään varsinaisen C-kääntäjän läpi
    • välissä saattaa olla välikieliä ja/tai assembler-koodia
    • jos oli välikieltä/assembleria niin viedään assembler-kääntäjän läpi
    • tuloksena on konekielinen objektitiedosto
  • Syntyneet konekieliset objektitiedostot yhdistetään lopulta suoritettavaksi ohjelmaksi tai monikäyttöiseksi apukirjastoksi linkkeriohjelmalla. Osa symboleista voidaan jättää linkitettäväksi jaetuista objekteista vasta käynnistämisen tai ajamisen yhteydessä.

Kun C-lähdekoodissa on rivi, joka alkaa risuaitamerkillä #, kyseessä on niin sanottu esikääntäjädirektiivi (preprocessor directive). Esimerkiksi ohjelman alussa olevat #include -rivit ovat tällaisia. Siinä kohtaa esikääntäjä etsii direktiivissä mainitun .h -tiedoston ja liittää ohjelmaan mukaan tiedoston sisällön aivan kuin se olisi kirjoitettu kyseiseen kohtaan lähdekoodia. Jos .h-tiedostossa on lisää #include tai muita direktiivejä, ne kaikki käsitellään, eli esikäännös on rekursiivinen prosessi. Periaatteessa varsinainen kääntäjä ("compiler proper") saattaa saada aika paljon pidemmän lähdekoodin pureskeltavakseen kuin alkuperäisestä .c:stä arvasikaan! Yhtä hyvin #include voi liittää koodin keskelle minkä tahansa tiedoston, eikä vain otsikkotiedostoja, mutta siitä voi seurata hankalasti ylläpidettäviä ohjelmia... Normaali käyttötarkoitus on tarvittavien otsikkotiedostojen sisällyttäminen ennen muun lähdekoodin alkua, aivan .c -tiedoston ensimmäisillä riveillä.

Usein käytettyjä esikääntäjädirektiivejä

Muita usein käytettyjä direktiivejä ovat seuraavat:

  • #define VAKIO 3.14159 määrittelee makron. Missä kohtaa lähdekoodia tuleekaan sen jälkeen vastaan sana VAKIO, se korvataan tekstillä 3.14159 esikääntäjän tulosteeseen. Näin voi määritellä vakioita, joita voi käyttää lausekkeissa, esimerkiksi ympari=2*VAKIO*r. Makrojen nimet kirjoitetaan yleensä isoilla kirjaimilla. Joitakin yleisesti käytettyjä on valmiina, joten tämän esimerkin sijasta kirjoita oikeasti ohjelman alkuun #include<math.h> ja sen jälkeen kirjoita esimerkiksi ympari=2*M_PI*r

    Kiinnostunut lukija voi varmistua itse, että esimerkiksi halavassa löytyy tiedosto /usr/include/math.h, johon on konkreettisesti kirjoitettu seuraava rivi:

    # define M_PI           3.14159265358979323846  /* pi */
    

    Koko tiedosto ja sen mukana tämäkin rivi tulee esikäännösvaiheessa konkreettisesti mukaan kaikkiin lähdekoodeihin, joissa lukee #include<math.h>. Kyseinen otsikkotiedosto math.h on määrätty mm. POSIXissa ja C99:ssä, joten sen käyttäminen on erittäin hyvä idea verrattuna omaan standardoimattomaan määritelmään ympyrän kehän ja halkaisijan pituuksien suhdeluvusta. Sen sijaan, jos ohjelmasi tarvitsee vakioarvon, jota ei ole määritelty standardissa, niin voit tehdä siitä C:n esikääntäjän makron, esimerkiksi:

    # define NIEMISEN_VAKIO 47.5  /* empiirisesti havaittu prosenttiosuus
                                     opiskelijoista, jotka ilmoittautuvat
                                     kurssille, mutta eivät palauta yhtään
                                     demotehtävää */
    
  • Pätkä:

    #ifdef OTA_KOODI_MUKAAN
       mulla = koodia * tassa + jotakin;
       printf("mutta en aina halua sitä mukaan\n");
    #endif
    

    tarkoittaa esikääntäjälle sitä, että #ifdef ja #endif -direktiivien välinen koodi tulee ottaa mukaan vain, jos makro nimeltä OTA_KOODI_MUKAAN on asetettu.

    Yleensä otsikkotiedostojen sisältö ympäröidään #ifndef eli "if not defined" menettelyllä:

    #ifndef OTSIKKO_X_ON_JO_LISATTY
    #define OTSIKKO_X_ON_JO_LISATTY
    ... varsinaiset otsikot ...
    #endif
    

    Tämä varmistaa, että jokainen tietorakenne ja aliohjelma tulee esitellyksi vain kerran -- C ei nimittäin salli saman nimen esittelyä enemmän kuin yhden kerran. Muuten voisi myös tulla ikuinen rekursio esikäännökseen, jos joku.h sisällyttää toinen.h:n, joka puolestaan sisällyttää joku.h:n.

    Tällä tavoin voidaan myös toteuttaa siirrettävää C-koodia, jossa sama lähdekoodi käy eri alustoille (poislukien vain just tietyt pätkät):

    #ifdef KAANNETAAN_WINDOWSILLE
       ... Windows-riippuvaista koodia ...
    #elif KAANNETAAN_LINUXILLE
       ... Linux-riippuvaista koodia ...
    #elif KAANNETAAN_POSIX_YHTEENSOPIVALLE
       ... POSIX-riippuvaista koodia ...
    #else
    #error "Ei ole toteutettu sinun käyttöjärjestelmällesi.."
    #endif
    

    Riippuen makrojen asetuksesta kääntäjä jättää jäljelle vain tietyn osan koodia. Direktiivi #error lopettaa käännöksen ja tulostaa direktiivissä määritellyn virheilmoituksen.

    Esimerkiksi Linuxin ytimen lähdekoodissa näkyy näitä todella paljon: tyypillisesti otetaan tai jätetään koodit, jotka valitaan konfigurointivaiheessa ennen kääntämistä. Näin saadaan käyttöjärjestelmän konekielinen toteutus kertakaikkiaan skaalautuvaksi: jos jotakin prosessoria, oheislaitetta tai ominaisuutta ei ole tarvetta käyttää, koko siihen liittyvä koodi voidaan jättää kääntämättä - se konkreettisesti häviää jo esikäännösvaiheessa, mikäli ominaisuutta vastaava makro ei ole asetettu #ifdef:in kohdalla! Voidaan siis esimerkiksi kääntää todella pieni "perus-Linux" jossa on mukana vain tietyn tietokoneyksilön tarvitsemien laitteiden ajurit. Yleiskäyttöisissä jakelupaketeissahan käännetään mukaan kaikkien Linuxin tukemien laitteiden ajurit, vaikkei yksittäinen käyttäjä koskaan tarvitse suurinta osaa niistä.

    Myös GNU-työkalujen otsikkotiedostot ovat täynnä näitä. Esimerkiksi otsikkotiedostojen standardiyhteensopivuudet on tehty seuraavalla tavoin:

    #ifdef __USE_ISOC99
    
    ... ja sen jälkeen tulee jotain, mitä pitää olla
        C99-standardin mukaisessa kirjastossa, mutta muuten ei.
        Koko pätkä häviää, jos työkalusto ei ole tässä kohtaa asettanut
        makroa __USE_ISOC99
    
    #endif
    
  • Pätkä:

    #if 0
    
       ... koodia ...
    
    #endif
    

    olennaisesti deletoi välissä olevan koodin ennen varsinaista käännöstä, koska #if 0 ehto ei ole koskaan tosi. Tällä tavoin voi kätevästi poistaa lähdekoodin osan käytöstä väliaikaisesti kokeilumielessä. Sen voi palauttaa helposti muuttamalla esikääntäjädirektiiviksi vaikka #if 1.

    Järkevä tekstieditori tai IDE osaa värittää #if 0:lla merkityn koodialueen esimerkiksi hailakan harmaaksi, niin silmä näkee paremmin, että sitä ei oikeastaan ole olemassa juuri nyt.

  • Makroista voi tehdä myös erehdyttävästi aliohjelmakutsun näköisiä. Kääntäjän mukana toimitettavat standardikirjastot saattavat käyttää syviä, esikäännöksessä rekursiivisesti avautuvia makrorakenteita, joissa standardin kirjastorajapinnan mukaiseksi kirjotettu koodi itse asiassa muuntuu hyvin erinäköiseksi, ennen kuin se menee eteenpäin varsinaiselle C-kääntäjälle.

    Hyvä puoli on, että tuo kääntäjävalmistajan kikkailu tapahtuu nimenomaan rajapinnan takana, ja sovellusohjelmoijana voidaan luottaa esimerkiksi C99:n ja POSIXin lupaamaan kirjoitusasuun, tapahtuipa sille mitä tahansa esi- tai varsinaisessa käännöksessä.

    Huono puoli on se, että kääntäjän ja kirjaston todellisen toiminnan seuraaminen ihan opiskelutarkoituksessakin on aika tikkuista puuhaa, koska C-koodi ei esikäännöksen vuoksi säily samanlaisena lopulliseen konekielikäännösvaiheeseen saakka.

C:n kääntämisen ja lähdekoodien hallinta on siis vähän monimutkaista. Onneksi uudemmissa kielissä, kuten C#:ssa ja Javassa, on ymmärretty tehdä järkevämpiä ratkaisuja. (C++:n kun puolestaan haluttiin pystyvän kääntämään myös C-koodia, niin siinä tämä esikäännös ja otsikkotiedostojen käyttö on ikävä kyllä ihan samanlaista).

Perustyökalu käännösprosessin hallintaan: Makefile

POSIXin edellyttämä työkalu "ohjelmistoprojektin" kokonaisuuden hallintaan, koodien kääntämiseen ja objektitiedostojen yhdistämiseen lopulliseksi tuotteeksi on kuorikomento make. Komennon käyttö edellyttää, että lähdekoodien yhteyteen on laadittu tietyn syntaksin mukainen kuvaustiedosto eli makefile, jossa on tiedot siitä, miten lähdekoodeista tulee kohdekoodia ja lopulta suoritettava ohjelma tai kirjasto.

POSIX määrää aika primitiivisen perusvekottimen, joten käytännössä on hyvä suosiolla käyttää esimerkiksi GNU Make -järjestelmää laajennoksineen ja aputyökaluineen.

Tällainen Make-järjestelmä hoitaa monia asioita automaattisesti: esimerkiksi se osaa tiedostojen aikaleimojen perusteella kääntää uudelleen vain ne objektit, joiden lähdekoodi on muuttunut. Erityisesti isojen projektien kohdalla tämä lyhentää käännökseen kuluvaa aikaa dramaattisesti pienen, vain muutamaa .c -tiedostoa koskeneen, koodimuutoksen jälkeen.

IDEt pitävät yllä vastaavia tietoja osana jonkinlaista "projektikuvausta". Muun muassa kääntäjäohjelman argumentit voivat olla erilaiset riippuen siitä, rakennetaanko ohjelma ilman optimointia ja debuggaustietojen kanssa ("Debug"-versio) vai jyrkällä optimoinnilla ilman debuggaustietoja ("Release"-versio). Makefilessä vastaava hoituisi C-kielisen ohjelman osalta sijoittamalla muuttujaan CFLAGS erilainen sisältö.

Koodien rakentamiseen on olemassa paljon makefileä näppärämpiä työkaluja, mutta niitä ei aivan pienessä projektissa tarvita. Esimerkiksi Linuxin lähdekoodin rakentaminen toimivaksi binääriksi lepää kyllä GNU-projektin toteuttaman make-järjestelmän päällä, mutta make-komennon kautta käynnistellään Linux-ytimen käännösvaiheessa skriptejä, apuohjelmia ja jopa graafisia käyttöliittymiä, joilla käännös voidaan konfiguroida interaktiivisesti.

Tyypit, muuttujat ja osoittimet, muistin käyttö

Tyypit, muuttujat, muisti, viite/osoitin, ... nämä ovat ohjelmoinnin yleisiä käsitteitä, joiden abstrakti merkitys on ymmärrettävä, että pystyy ohjelmoimaan erilaisilla kielillä. Syntaksit ja sisäisen toiminnan nyanssit ovat erilaisia, mutta käsitteet ja toimintaperiaatteet ovat samoja monissa kielissä.

Kertaus: mikä olikaan muuttuja

Ohjelmoinnissa voi yleensä määritellä muuttujia. Niillä on nimet, joiden kautta niihin pääsee käsiksi, niihin voi tallentaa dataa operaatioiden suorittamista varten (eli "sijoittaa arvoja muuttujiin"), ja niitä voi käyttää lausekkeissa laskemiseen.

Staattisesti tyypitetyissä kielissä, jollaisia esimerkiksi C#, Java ja C ovat, muuttujien käyttöön liittyy rajoituksia. Muun muassa muuttujat pitää esitellä aina tietyn tyyppisiksi, eikä muuttujan tyyppiä voi enää esittelyn jälkeen vaihtaa. Muuttujien tyypit on tunnettava jo ohjelmaa käännettäessä joka tilanteessa.

C#:ssa ja Javassa muuttujat jakautuvat primitiivityyppisiin (C#:n terminologian mukaan arvotyyppisiin) kuten int, double, boolean ja niin edelleen, joiden arvot elävät pinomuistissa sellaisenaan, ja olioviitteisiin, joiden tyyppiin kuuluu tieto siitä, minkä luokan (tai tästä perityn aliluokan) olioon viite vain voi osoittaa.

Viitteiden kohteena olevat oliot eivät ole suorittavan (virtuaalisen) koneen pinossa arvoina samaan tapaan kuin arvotyyppiset muuttujat, vaan kukin viite osoittaa ns. kekomuistissa sijaitsevaan, mahdollisesti hyvinkin monimuotoiseen dataan, jonka ominaisuudet ja käyttäytymisen viitetyyppi (eli olioluokka metodeineen) kuvaa.

Muuttujat eivät näissä kielissä voi olla muunlaisia kuin joko yksittäisiä arvoja tai viitteitä. Pelikenttä on pohjimmiltaan näinkin yksinkertainen. Taulukot ja merkkijonot ovat niissä olioita, joita käytetään vastaavantyyppisten viitteiden kautta. Ne ovat kuitenkin jopa niin usein tarvittuja olioluokkia, että kieliin on nähty tarpeelliseksi määritellä oma syntaksinsa niiden käyttöön ja niiden toteutuksessa saatetaan käyttää suorituskykyä parantavia optimointikeinoja konepellin alla.

Myös C-kielessä on tietyt ''primitiivityyppit'' tai ''arvotyypit'', jotka pitkälti vastaavat C:stä periytyvien oliokielten, kuten C#:n, vastaavia, suoraan lukuarvoina ilmeneviä, tyyppejä. Niiden lisäksi C:ssä on:

  • ohjelmoijan määrittelemiä struktuurityyppejä (järjestettyjä yhdistelmiä muista tyypeistä; vähän niinkuin luokkia, joilla on vain julkisia attribuutteja eikä yhtään metodia; struktuurityypeissä voi olla sisällä muita struktuureja, eli rakenne voi olla hierarkkinen). Muita nimiä struktuurille voisi olla rakenne tai tietue.
  • taulukkotyyppi kustakin primitiivityypistä tai keskenään samantyyppisistä struktuureista.

Kaikkien edellämainittujen tyyppien mukaiset arvot tallentuvat C-ohjelmassa muistiin peräkkäisinä tavuina, joiden määrä ja järjestys tiedetään käännösvaiheessa. Välissä saattaa olla käyttämätöntä tilaa, mikäli esimerkiksi jotkut arvot ovat yksitavuisia ja jotkut monitavuisia, mutta prosessoriarkkitehtuuri vaatii että kaikki arvot sijaitsevat vähintään kahdella jaollisessa muistiosoitteessa. Joka tapauksessa kaikkeen arvo-, struktuuri- ja taulukkodataan pääsee käsiksi valmiiksi käännösvaiheessa lasketulla muistiosoitteella. Näitä muistiosoitteita voi C-kielessä käsitellä suoraan:

  • jokaisen tyypin mukaiselle datalle voi määritellä osoittimen, joka kuvaa yhden data-alkion ensimmäisen tavun muistiosoitetta.

Jokainen osoitin on C-kielessä yksinkertaisesti vain kokonaisluku, jossa on riittävän monta bittiä kääntäjän kohteena olevan prosessoriarkkitehtuurin muistiosoitteen tallentamiseksi. Esimerkiksi x86-64:ssä osoittimet ovat 64-bittisiä etumerkittömiä kokonaislukuja. Sen sijaan jollekin 32-bittiselle prosessorille käännettäessä muistiosoitteet olisivat 32-bittisiä.

Vaikka C:ssä osoittimet ovat konepellin alla kokonaislukuja, niiden semantiikka eli merkitys on viitteen kaltainen, eli osoittimella osoitetaan jonkin muun datan sijaintia muistissa ihan samoin kuin oliokielessä viitteellä viitataan olioinstanssiin. Viitemuuttuja ei ole olio, johon se viittaa. Myöskään osoitinmuuttuja ei ole se kohde, johon se osoittaa. Tätä on hyvä makustella, kun opettelee asioita ensimmäistä kertaa. C-kielen ja konekielen opettelu tekee hyvin konkreettiseksi sen, miten myös oliokielten viitteet saattavat olla toteutettu konepellin alla.

Vaikka osoittimet ovat keskenään samanlaisia kokonaislukuja, niiden määrittelyssä pitää ilmoittaa myös niiden osoittaman datan tyyppi, jotta tästä viitesemantiikasta tulee täydellinen ja turvallisempi käyttää. Ilman tietoa tyypistä kääntäjä ei saisi laskettua muistiosoitteita peräkkäin talletetun taulukon sisään, eikä edes rakenteisen struktuurin/tietueen sisäisiin osioihin.

Tyypeistä, viitteistä ja C:n osoittimista

Tyypit kuvaavat sitä, minkä muotoisia tietoja muuttujissa tai muissa kohteissa (oliot tai tietorakenteet) pidetään tallessa ohjelman suorituksen aikana. Arvotyyppien sisältö on käytettävissä saman tien, sellaisenaan kuin se on; viitteen kautta käsiteltävä objekti sijaitsee jonkinlaisen muistiosoituksen tai vastaavan päässä.

C#:ssa ja Javassa olioiden attribuutit eli niiden sisältämä data sijoittuu kekomuistiin omien olioinstanssiensa osana. Virtuaalikone varaa kekomuistista tilaa attribuuteille aina kun tavukoodi käskee luomaan eli instantoimaan uuden olion. Sitten tapahtuu aliohjelmakutsu luokan konstruktorimetodiin. Metodien parametrit sekä niiden lokaalit muuttujat sen sijaan sijoittuvat pinomuistiin, jota tavukoodin virtuaalikonekäskyt käyttävät laskentaoperaatioissa. Olioviite, olipa se lokaali muuttuja, metodin parametri tai olion attribuutti, on arvoltaan olennaisesti jonkun kekomuistissa olevan olion sijaintitieto. Se voi olla myös null silloin, kun viite ei juuri tällä hetkellä viittaa/osoita mihinkään.

Oliokielen viitekin on siis siinä mielessä "osoitin", että se "osoittaa" olioon. Oliokielissä kuitenkaan ei puhuta osoittimesta, koska termi viite (engl. reference) vapauttaa semantiikan laitteistosta, muistiosoitteista ja kokonaisluvuista tai muista konkreettisista toteutukseen liittyvistä yksityiskohdista - viitteen toteutus voi olla sisäisesti mitä tahansa, kunhan se semanttisesti viittaa olioinstanssiin tai ei mihinkään (null-viittaus). Jollain tapaa viitteet kuitenkin täytyy toteuttaa, että kielillä tehtyjä ohjelmia voidaan oikeasti suorittaa. Yksinkertaisimmillaan viitteen toteutus voisi olla juokseva numerointi ohjelman alusta alkaen syntyneisiin olioinstansseihin tai jopa suora muistiosoite dataan, jossa olioinstanssin tiedot löytyvät. (Mm. perintä tekee oliokielten sisäisen toteutuksen monimutkaisemmaksi, koska koko perintähierarkian mukaisten metodien koodit täytyy olla löytyvillä: tervemenoa jatkokursseille ohjelmointikielten periaatteista ja kääntäjätekniikasta ym., mikäli kielten, kääntäjien ja tulkkien teknologia alkaa kiinnostaa enemmän).

C-kielen tapa käyttää muistia on laitteistoläheinen ja laitteiston näkökulmasta paljon yksinkertaisempi kuin oliokielissä. Muuttujilla, osoittimet mukaanlukien, on C:ssä yksi-yhteen vastaavuus laitteistossa suoritettavan prosessin muistiavaruuden sisällön kanssa:

  • Lokaalit tietorakenteet (primitiivimuuttujat ja myös struktuurina määritellyt lokaalit muuttujat) sijaitsevat suorituspinossa kulloisenkin aliohjelman pinokehyksessä (ks. luennot ja materiaali, joka kertoo suorituspinosta ja pinokehyksestä)
  • globaalit, ennakkoon alustetut rakenteet sijaitsevat data-alueella
  • dynaamisesti varatut tietorakenteet ovat niille varatulla muistialueella, jota sanotaan keoksi myös C-kielessä.

Käyttöjärjestelmän lataaja ja latauslinkittäjä kyhäävät käynnistettävää ohjelmaa varten virtuaalimuistiavaruuden, jossa objektitiedostossa listatut ennaltamäärätyt koodit, datat, nollaksi alustettu aputila, keko sekä dynaamisten kirjastojen tarvittavat osiot sijaitsevat siinä vaiheessa, kun käyttöjärjestelmä antaa prosessorin ryhtyä suorittamaan käskyjä ohjelmatiedostossa ilmoitetusta aloitusmuistiosoitteesta alkaen. Jos siis ei tullut ongelmia matkalla.

C:n primitiivityyppejä käytetään kuten C#:ssa tai Javassa, eli niiden arvoilla voidaan laskea lausekkeissa. Kääntäjä osaa tuottaa ohjelmakoodin, joka sisältää lokaalien muuttujien oikeat muistiosoitteet suhteessa kullakin hetkellä suorituksessa olevan aliohjelma-aktivaation pinokehyksen sijaintiin (edelleen ks. luennot ja moniste). Tämän lisäksi mihin tahansa muuttujaan voidaan tarvittaessa tehdä osoitin, joka on konkreettisesti alla olevan prosessoriarkkitehtuurin virtuaalimuistiosoite.

Osoitin eli muistiosoite mihin tahansa muuttujaan saadaan C:ssä kirjoittamalla et-merkillä &muuttujan_nimi ja osoittimen osoittamaan arvoon päästään käsiksi tähdellä *osoittimen_nimi. Suomenkielisenä muistisääntönä "tähdätään" arvoon osoitinta pitkin. Englanninkieliset nimet operaattoreille ovat C99-standardin sisällysluettelon mukaan address operator & ja indirection operator *. Ensimmäisen etymologia liittyy toteutukseen muistiosoitteena (address) ja jälkimmäisen sisältödatan "epäsuoraan" (indirect) käyttöön, eli suoraan saadaan vain muistiosoite ja varsinaisen sisällön saantiin tarvitaan vielä uusi muistihaku tuon osoitteen perusteella.

Taulukoiden alkioiden arvoihin päästään syntaksilla taulukon_alun_osoite[indeksi] ja osoittimen päässä olevan tietorakenteen nimettyyn kenttään/attribuuttiin päästään nuolen näköisellä viivan ja suurempi kuin -merkin yhdistelmällä tietorakenteen_osoite->kentan_nimi. Tämän nimi on standardissa structure pointer operator -> ja "kenttäväki" näyttää tuntevan tämän nimellä arrow operator. Suomenkielisenä muistisääntönä: arvoon pystyy "osumaan nuolella" osoittimen kautta.

Seuraavassa koodissa tehdään osoitin kokonaislukuun ja sitä käytetään perinteisen aloittelijan ymmärrysvirheen takia kauhistuttavan väärin ja vaarallisesti:

/** Aliohjelma, joka palauttaa osoittimen kokonaislukuun:
 * Paluuarvon tyyppi on "int*", missä tähti tarkoittaa osoitinta.
 */

int* tyhma_aliohjelma(){

  int kokonaisluku;
  int *osoitin;

  /* Muuttujan nimeltä 'kokonaisluku' arvo sijaitsee pinokehyksestä
   * sille varatussa paikassa. Samoin muistiosoite nimeltä 'osoitin'.
   * C-kielessä ei luvata kummallekaan mitään tiettyä alkuarvoa.
   * Tässä vaiheessa voivat olla siis mitä tahansa. Erittäin helppo
   * joutua tämän(kin) ominaisuuden vuoksi vaikeuksiin! Kääntäjän
   * varoitukset on syytä laittaa päälle, ja niitä on syytä myös
   * lukea ja ymmärtää!
   */

  /* Sijoitetaan seuraavassa muuttujaan vakioluku 123;
   * käytännössä pinomuistin vastaavaan kohtaan
   * menee silloin lukuarvo 123:
   */

  kokonaisluku = 123;

  /* Osoitinkin on lukuarvo pinossa, mutta sen merkitys on olla
   * jonkun toisen muuttujan muistiosoite; tässä tapauksessa
   * sijoitetaan osoittimen pinokehyspaikkaan kokonaisluku:n
   * pinopaikan muistiosoite:
   */

  osoitin = &kokonaisluku;

  /* Funtsi juttua, ja piirrä tarvittaessa muistin sisällöstä kuvia
   * paperille, ja kysy jos ei muuten hahmotu! Debuggeria meidän pitäisi
   * veivata luennolla, demossa ja oma-aloitteisesti, kunnes tämä on
   * päivänselvää ja selkäytimessä!
   */

  /* Alla oleva on tässä tapauksessa JÄRKYTTÄVÄÄ JA VÄÄRIN
   * koska aliohjelmasta palautetaan muistiosoite, joka viittaa
   * nykyisen pinokehyksen sisään. Tämä kehys lakkaa olemasta
   * käytössä returnissa, mutta joku ehkä kuvittelee, että palautettu
   * muistiosoite tarkoittaisi aliohjelma-aktivaation
   * päätyttyäkin jotain järkevää. Ei tarkoita.
   *
   * *************************************************************
   * *   Se on virtuaalimuistiosoite, joka osoittaa pinoalueen   *
   * *   sisään. Pinon sisältö vaihtelee aliohjelmakutsujen ja   *
   * *   niistä palaamisen yhteydessä. Muistipaikan sallittu     *
   * *   käyttö kokonaisluku -nimisen muuttujan säilömiseen      *
   * *   loppuu heti tuon seuraavan returnin suoritukseen:       *
   * *************************************************************
   *
   */

  return osoitin;
}

Edellisestä koodista kyllä palautuu muistiosoite, mutta mitään takeita kyseisen muistipaikan sisällöstä ei ole enää kutsusta palaamisen jälkeen!

Eli C:ssä käsitellään asioita hyvin laiteläheisesti. On helppo tehdä erittäin pahoja ohjelmointivirheitä osoittimien kanssa. Kääntäjä ei (aina) osaa varoittaa loogisesti väärin käytetyistä osoittimista, ja ajoaikana varsinkaan ei ole laitteiston ja C-kielestä konekielelle käännetyn ohjelman välissä mitään tuplavarmistuksia kuten C#:ssa tai Javassa on mahdollista virtuaalikoneen kautta toteuttaa. Nämä asiat on syytä ymmärtää mahdollisimman varhaisessa vaiheessa, ennen kuin syystä tai toisesta joutuu esimerkiksi töissä kirjoittamaan tai muokkaamaan C-koodia! Mainittu skenaario on erittäin mahdollinen, koska C on tällä hetkellä (huhtikuu 2022) TIOBE Indexissä maailman toiseksi suosituin kieli, oltiinpa siitä mitä mieltä tahansa...

(Täytyy muistaa, että koodin optimointi voi vähän sekavoittaa kuvioita; muuttujan arvoa voidaan nimittäin pitää prosessorin rekisterissä, jolloin se on paljon nopeammin saatavilla laskutoimituksiin kuin pinomuistista. Tehdään tällä kurssilla kaikki käännökset ilman optimointia kääntäjän argumentilla -O0, jotta perusideat selviävät käännöksistä).

Miten edellinen esimerkki sitten olisi tehty niin sanotusi "oikein"? No oikeasti tuollaista aliohjelmaa tuskin tehtäisiin, koska eihän se tee mitään järkevää muutenkaan. Jos tarkoitus on laskea jokin kokonaisluku, niin varmaan ylipäätään tehtäisiin aliohjelma, jonka paluuarvon tyyppi on kokonaisluku eikä osoitin sellaiseen. Alempana tulee järkevämpi esimerkki, jossa luodaan kokonaislukutaulukko. Sellainen isompi rakennelma voi olla viisasta luoda aliohjelmassa, joka tosiaan palauttaa muistiosoitteen.

Osoittimet ovat käyttötarkoitukseltaan ikään kuin olioviitteet, mutta C-kielessä kaikessa yksinkertaisuudessaan osoitin osoittaa tavun mittaista muistipaikkaa. Osoitetusta muistipaikasta voi alkaa yksi tietyn tyyppinen primitiivialkio kuten 8-bittinen tai 64-bittinen luku. Tai yhtä hyvin siitä voi alkaa muistialue, joka sisältää vaikka miten monimutkaisen ja ison tietorakenteen.

Maistelepa vielä kertauksen vuoksi seuraavia sanontoja, joita voisi ihminen päästää suustaan:

  • "Viitemuuttuja osoittaa olioinstanssiin"
  • "Osoitinmuuttuja viittaa tietorakenteeseen"
  • "Viite olioon"
  • "Osoitin tietorakenteeseen".

Näistä toivottavasti hahmottuisi tietty yhteys käsitteiden välillä! Jos ymmärrät viitteen niin ymmärrät pienellä vaivalla osoittimenkin, tai toisin päin. Puhuessaan ihminen voi vahingossa päästää suustaan jomman kumman näistä melkein samaa tarkoittavista sanoista... (Kirjoittaessa voi yrittää parempaa tarkkuutta terminologiassa, kun backspace-näppäin ja tekstieditorin undo-toiminto ovat käytössä.)

Opiskelijan kysymys: Ovatko edellä esitetyt lausumat tosia?

Opettajan vastaus: Jaa-a. Kyllä ne melkein ovat. Huomattavia lillukanvarsia olisi edessä siellä, missä käytäisiin rajaa kohteeseen viittaamisen ja kohteen osoittamisen välillä. Kun laitat etusormesi kohti lähintä kattovalaisinta, niin osoitatko sitä vai viittaatko sitä päin?

Tärkeintä lienee koettaa hahmottaa, mitä tarkoittaa se, että operaatioita kohdistetaan entiteettiin, joka ei ole "juuri tässä", mutta jonka sijainti tunnetaan (eli käytössä on "viite" tai "osoite") jotta kyseiseen entiteettiin on mahdollisuus päästä käsiksi epäsuorasti, sijaintitietojen perusteella. Oliokielissä oliot ovat ajon aikana kekomuistissa ja suurin osa asioista tapahtuu epäsuorasti viitteiden kautta. Saman voi tehdä C-kielessä osoittimien avulla. Ne on vaan tylsästi alla olevan arkkitehtuurin virtuaalimuistiosoitteita, mutta epäsuoria "viitteitä olioihin" yhtä kaikki.

Lausuma "osoitin on viite" on aika lailla totta, koska muistiosoitteita käytettiin nykyisen olioviitteen roolissa aikana, jolloin ei vielä ollut oliokieliä ja nykyistä "viitteen" käsitettä.

Lausuma "viite on osoitin" ei ole aivan yhtä totta, jos tarkoitetaan jäykästi (kuten C-kielessä), että "viite on muistiosoite prosessin virtuaalimuistiavaruuteen". Se voi kuitenkin olla totta silloin, jos kielen sisäisessä toteutuksessa viite itse asiassa onkin muistiosoite johonkin tietorakenteeseen, jossa pidetään kirjaa olioinstanssin elämästä.

Arvotyypit (eli ''primitiivityypit'')

C#:ssa ja Javassa spesifikaatiot kertovat, minkä kokoisia (bittien lukumäärällä mitattuna) mitkäkin arvotyypit ovat. Viitemuuttujia ei voi näissä kielissä käyttää laskemiseen, joten niiden sisäisellä toteutustavalla ei ole ohjelmoijalle mitään väliä, koska toteutus on kielijärjestelmän rajapinnan takana.

Perinteisen C-kielen arvotyyppien ja osoittimien koko (ikävä kyllä) riippuu prosessoriarkkitehtuurista, jolle kääntäjä on tehty! Standardi määrittelee vain minimipituudet. Tästä päästiin kuitenkin eteenpäin C99-standardissa (ja siten mm. POSIXissa). C99:n peruskirjastossa tulee olla otsikkotiedosto stdint.h, jota käyttämällä ohjelmoija voi ihan suorastaan määrätä, kuinka monta bittiä hän kokonaislukuunsa haluaa. Matemaattisessa laskennassa tarvittaville liukuluvuille ja niiden binääriesityksille on olemassa omat standardinsa, joita C99 myöskin tukee.

Koska niitä tulee vanhassa koodissa joskus vastaan, katsotaan ensin itse C-kielessä, ilman yhteensopivuuskirjastoa stdint.h, määritellyt kokonaisluvut, joiden bittimäärä saattaa vaihdella kääntäjästä riippuen (varottava siis luottamasta näiden arvoalueeseen):

unsigned char             - etumerkitön 8-bittinen (tai laajempi) luku
signed char               - etumerkillinen 8-bittinen (tai laajempi) luku
char                      - 8-bittinen luku (riippuu kääntäjästä onko
                            etumerkillinen vai ei; eli siirrettävässä
                            koodissa syytä sanoa eksplisiittisesti)

unsigned short int        - 16 bittinen luku, etumerkilliset ja -merkittömät
signed short int
short int

       (voi kirjoittaa myös "unsigned short", "signed short",
       "short" eli voi kirjoittaa ilman int'iä)

unsigned int              - 16 tai 32 bittinen tai muun kokoinen luku,
signed int                  tarkistettava koko aina kääntäjän speksistä.
int                         Jalavan GCC:ssä ilmeisesti 32-bittisiä

       (voi kirjoittaa myös "unsigned", "signed" eli ilman int'iä)

unsigned long int         - mahdollisesti useampibittinen kuin pelkkä int
signed long int             tarkistettava aina kääntäjän speksistä
long int

       (nämäkin voi kirjoittaa ilman int'iä)

unsigned long long int    - mahdollisesti vieläkin useampibittinen
signed long long int        tarkistettava aina kääntäjän speksistä;
long long int               ei edes löydy vanhemmista C-standardeista.

       (nämäkin voi kirjoittaa ilman int'iä)

Liukuluvut:

float                      - single precision (saattaa olla 32-bittinen tai ei)

double                     - double precision (saattaa olla 64-bittinen tai ei)

long double                - extended double precision (tarkempi kuin double)

Ei-mitään -tyyppi:

void                       - "ei mitään"; käytettävä aliohjelman
                             esittelyssä, jos paluuarvoa ei ole.
                             Voi myös tehdä void* -tyyppisiä
                             osoittimia, millä voi kiertää osoittimen
                             vahvan tyypityksen; mihin tahansa objektiin
                             voi osoittaa void-osoittimella. "Vaarallista",
                             mutta mahdollista, kuten moni muukin juttu.
                             (Ainoa tapa "geneeriseen" ohjelmointiin, jossa
                             halutaan toteuttaa abstrakti tietorakenne mille
                             tahansa alkiotyypille.)

Huomioita:

  • "Merkki" eli char on kokonaisluku; sillä voi ajatella koodaavansa ASCII-merkin tai minkä tahansa muun asian, joka numeroituu melko vähillä biteillä (esimerkiksi ASCII 7 bitillä, välille 0..127)

    Merkeillä voi laskea yhteen tai vähentää, ne kun on vaan vähäbittisiä lukuja... 'A'+2 == 'C' jos merkistökoodaus on esimerkiksi ASCII, jossa 'A', 'B' ja 'C' ovat peräkkäisillä luvuilla koodattu. Huom: POSIX määrää (muistaakseni, tarkista halutessasi itse), että merkit 0,1,2,3,4,5,6,7,8,9 ovat koodattu järjestyksessä peräkkäisiin kokonaislukuihin. Minkään muiden merkkien osalta POSIX ei määrää pakollista järjestystä, vaan esimerkiksi aakkosjärjestyksen luominen vaatii, että käytetyt kieli- ja merkistöasetukset tunnetaan ja sovelletaan oikeata järjestystä.

  • Uudemmista kielistä tuttua boolean tai bool -totuusarvotyyppiä ei ikivanhassa C:ssä ollut sellaisenaan.. C99 määrittelee sen hiukan hassulla nimellä _Bool kokonaisluvuksi, jossa on vähintään yksi bitti. Mikä tahansa kokonaisluku kelpaa C:ssä totuusarvoksi, ja tulkinta on, että 0 on epätosi ja kaikki nollasta poikkeavat arvot tarkoittavat totta. Vertailuoperaattoreiden käyttö loogisessa lausekkeessa koodaa standardin mukaan epätoden kokonaisluvulla 0 ja toden kokonaisluvulla 1.

    C:n jälkeen suunnitelluissa ohjelmointikielissä totuusarvojen käsittely on tehty tavalla tai toisella selkeämmäksi, mutta C-kielen osalta täytyy "mennä niillä mitä on" (ja oli 1970-luvulla).

    Standardikirjaston otsikkotiedosto stdbool.h määrittelee tyypille ja totuusarvoille nätimmät nimet bool, true ja false, ja näitä onkin ihan fiksua käyttää C99-ohjelmissa totuusarvojen ilmaisemiseen. Luonnollisesti käyttö edellyttää lähdekooditiedoston alussa direktiivin #include<stdbool.h>.

    (Määritelmäthän ovat tietysti esikääntäjän makroja, jotka korvautuvat koodissa teksteillä ''_Bool'', ''1'' ja ''0'' ennen varsinaista C-käännöstä... suorakäyttökoneillamme keväällä 2015 tämän tiedoston sijainti on /usr/lib/gcc/x86_64-redhat-linux/4.4.4/include/stdbool.h jos joku haluaa käydä kurkkaamassa, miten C:n standardirajapintaa yksinkertaisimmillaan tuetaan kääntäjän tekijän toimesta. Tässä tapauksessa otsikkotiedostossa on kommenttien lisäksi pelkkiä esikääntäjädirektiivejä 14 kappaletta.)

Katsotaan sitten kokonaislukutyypit siten kuin C99-standardin yhteensopivuuskirjaston stdint.h -otsikkotiedoston vähimmillään tulee ne määritellä:

int_least8_t          - Vähintään 8-bittinen etumerkillinen kokonaisluku
int_least16_t         - Vähintään 16-bittinen etumerkillinen kokonaisluku
int_least32_t         - Vähintään 32-bittinen etumerkillinen kokonaisluku
int_least64_t         - Vähintään 64-bittinen etumerkillinen kokonaisluku

uint_least8_t         - Vähintään 8-bittinen etumerkitön kokonaisluku
uint_least16_t        - Vähintään 16-bittinen etumerkitön kokonaisluku
uint_least32_t        - Vähintään 32-bittinen etumerkitön kokonaisluku
uint_least64_t        - Vähintään 64-bittinen etumerkitön kokonaisluku

Käytännössä nämä ovat kääntäjän ja standardikirjaston tekijän määrittämiä synonyymejä aiemmassa listassa olleille pidemmille tyyppinimille, jotka pohjalla olevan C-kielen spesifikaatio määrittelee kiinnittämättä niiden pituutta tarkemmin. Hyvä puoli näiden kirjastosta löytyvien tyyppinimien käytössä on, että jopa jo vuodesta 1999 alkaen (kun C-kieli oli noin 30 vuotta vanha) saatiin määriteltyä yhteisiä sopimuksia, joiden perusteella ohjelmoija voi tietää, että yhteenlaskun tulos mahtuu oikeasti siihen muuttujaan, johon hän summansa laskee, riippumatta siitä, minkä valmistajan kääntäjällä ja mille laitteistolle koodi käännetään. Uudemmissa kielissä, kuten esimerkiksi Javassa ja sittemmin C#:ssa, on osattu ottaa alusta lähtien huomioon, että bittien määrällä on väliä, joten ne on otettu huomioon jo ensimmäisissä määritelmäversioissa tarkemmin kuin alkuperäisessä C:ssä.

Katsotaan vielä kiinteän kokoiset kokonaislukutyypit tasan 8-, 16-, 32- ja 64-bittisille luvuille. Standardi sallii, että näitä ei tarvitse olla olemassa, mikäli käännetään laitteistoon, joka ei tue näitä nimenomaisia kokonaislukutyyppejä. Kuitenkin jos kääntäjän on mahdollisuus toteuttaa jokin tai kaikki näistä lukutyypeistä järkevällä tavoin, niin siinä tapauksessa standardin mukaisen kirjaston stdint.h -tiedoston täytyy määritellä kyseiset nimet juuri tällä tavoin:

int8_t       - Tasan 8-bittinen etumerkillinen kokonaisluku, jossa
               negatiiviset luvut esitetään erityisesti ns. "kahden
               komplementtimuodossa": -1 = 0xFF, -2 = 0xFE, jne..
               pienin mahdollinen luku on -128 = 0x80 ja suurin
               mahdollinen luku on 127 = 0x7F. Tällainen lukualue
               "pyörähtää ympäri" ylimmän bitin asettuessa:

               ... 126 = 0x7E, 127 = 0x7F, -128 = 0x80, -127 = 0x81 ...

               Etumerkillisten lukujen ympäripyörähdys juuri tällä
               tapaa perustuu sinänsä yleisesti käytettyyn
               2-komplementtiesitykseen, jota C99 eksplisiittisesti
               EI lupaa tyyppimuunnosten yhteydessä. Koodia, joka
               luottaisi tähän ilmiöön, ei missään nimessä saisi
               kirjoittaa.

int16_t      - Tasan 16-bittinen etumerkillinen kokonaisluku (2-kompl.)
               pienin luku -32768 = 0x8000, suurin luku 32767 = 0x7FFF

int32_t      - Tasan 32-bittinen etumerkillinen kokonaisluku (2-kompl.)
               pienin -2147483648 = 0x80000000, suurin 2147483647 = 0x7fffffff

int64_t      - Tasan 64-bittinen etumerkillinen kokonaisluku (2-kompl.)
               pienin -9223372036854775808 = 0x8000000000000000
               suurin  9223372036854775807 = 0x7fffffffffffffff

uint8_t      - Tasan 8-bittinen etumerkitön kokonaisluku, alue 0..255

               Etumerkittömien kokonaislukujen ympäripyörähdys on
               C99:ssä luvattu tapahtuvan suurimmasta luvusta
               nollaan, joten "modulo 256" -laskureita voi huoletta
               tehdä uint8_t -tyypillä, mutta ei int8_t -tyypillä,
               kuten edellä todettiin.

uint16_t     - Tasan 16-bittinen etumerkitön kokonaisluku, alue 0..65535

uint32_t     - Tasan 32-bittinen etumerkitön kokonaisluku, alue 0..0xffffffff

uint64_t     - Tasan 64-bittinen etumerkitön kokonaisluku,
               alue 0..0xffffffffffffffff

               (maksimi on 10-järjestelmässä jotakin useampinumeroista..
               kaksi potenssiin 64 miinus 1. Hintsusti suurempi kuin
               18 triljoonaa eli 18 * 10^18. Heksaesitys riittää meille.)

Useimpiin tarkoituksiin riittää käyttää vähimmäispituuden määrittäviä kokonaislukuja, jotka C99:ssä on välttämättä määritelty. Ohjelmoijan vastuulla on miettiä, kuinka suuria kokonaislukuja hän suurimmillaan käsittelee ja että valittu tyyppi riittää niiden käsittelyyn. Joskus täsmällisellä pituudella saattaa olla väliä, jolloin joutuu maksamaan sen hinnan, että standardin määritelmätekstinkin mukaan ohjelman voi kääntää vain niille alustoille, jotka sattuvat tukemaan juuri kyseisiä lukuesityksiä. (Sovellukset ehdottoman tasamittaisille luvuille todennäköisesti muutenkin liittyvät tiettyyn laitteistoon, joissa jokin tallennustila tai -muoto edellyttää bitti bitiltä tietyn muotoista dataa.)

Hieno homma tämän yksinkertaistetun johdantokurssin kannalta on, että tyyppi uint64_t sattuu olemaan täsmälleen esimerkkilaitteiston eli x86-64:n yleisrekisterien ja muistiosoitteiden mittainen, ja kahden komplementtiesitystäkin laite käyttää, joten etumerkillinen int64_t toimii C-kielessä täysin samoin kuin konekielessä. Debugger-esimerkit saadaan näyttämään nätiltä ja yksinkertaiselta, kun C-koodeissa käytellään näitä tyyppejä ja osoittimia, jotka ovat luonnostaan saman kokoisia!

Kun lähdet tämän kurssin jälkeen jatkokursseille tai oikeaan maailmaan, törmäät hyvin pian siihen, että ohjelmat käyttävät vaihtelevan kokoisia kokonaislukuja, jotka täyttävät kerrallaan vain osan 64-bittisestä rekisteristä! Selviydyt siitä kyllä, kun olet ottanut perusideat huolella haltuun pelkkiä 64-bittisiä int64_t -muuttujia käyttämällä.

Struktuuri- eli tietuetyypit eli ohjelmoijan määrittelemät koostetut tyypit

Lokaaleille primitiivityypeille varataan tilaa pinosta tai globaalilta data-alueelta sen verran kuin ne tarvitsevat. C-ohjelmoija voi koostaa primitiivityypeistä isompia ns. tietueita eli struktuureja, jotka ilmenevät peräkkäisiin osoitteisiin sijoittuvana muistitilana kaikille jäsenilleen. Esimerkiksi seuraavaa tyyppiä voisi käyttää pistemäisen massan kuvaamiseen vaikkapa pelissä, jossa heitetään tykinkuulia (tai vihaisia lintuja tai muuta mitä nyt yleensä voi heittää):

typedef struct {
    double x, y, z;  // Sijainti kolmiulotteisessa avaruudessa
    double massa;    // Kappaleen massa
    int idnumero;    // Kappaleen yksilöivä tunnistetieto
} pistemassa3d;

Käyttöesim:

pistemassa3d kappaleA;    /* kappaleA olisi pistemassa3d -tyyppinen*/

kappaleA.y = -3.0;        /* jäseniin pääsisi käsiksi näin*/
kappaleA.massa = 48.0;    /* jäseniin pääsisi käsiksi näin*/

...

Yllä sijoitus kappaleA.y = -3.0 asettaisi liukuluvun -3.0 muistipaikkaan, joka on varattu kyseiselle tietuekentälle. Kenttä sijaitsee tietueelle varatun kokonaistilan sisällä. Kääntäjä pitää kirjaa siitä, mikä mm. y-kentän osoite on suhteessa kokonaisuuteen. Ohjelmoijalle päin tämä näyttää periaatteessa samalta kuin oliokielessä luokka, jossa on vain julkisia attribuutteja eikä yhtään metodia. Kuitenkin käsitteellisiä eroja on: C-kielessä struktuuri kaikkine arvoineen syntyy oletuksena lokaaliin pinoon eikä mihinkään eri paikkaan, kuten olioille varattuun kekomuistiin. Ja yllä esimerkissä kappaleA ei ole semanttisesti millään tavoin viite tai osoitin, vaan se tarkoittaa koko datakönttää. Siis sijoitus:

pistemassa3d kappaleA, kappaleB;

...

kappaleB = kappaleA;

aiheuttaisi koko kappaleA -tietueen sisällön kopioitumisen kenttä kentältä kappaleB -tietueen vastaaviin kenttiin (käytännössä tapahtuisi tavujen kopiointi muistissa paikasta toiseen). Jotta turhilta siirroilta vältyttäisiin, ja jotta voitaisiin käyttää dynaamista muistinvarausta ja kekomuistia C:ssä, tarvitaan osoittimia yleensä aivan välttämättä.

Osoitintyypit

Mihin tahansa muuttujaan voidaan viitata osoittimella, eli voidaan esitellä vastaavan tyyppinen osoitinmuuttuja, johon voidaan sijoittaa viitattavan muuttujan muistiosoite. Lisäksi, koska osoitinkin on olennaisesti muuttuja, voidaan siihen tehdä osoitin! Siis seuraavanlaiset muuttujat ovat C:ssä yleisiä:

double luku;           /* Primitiivityyppi, liukuluku nimeltä 'luku'*/
double *pluku;         /* osoitintyyppi! Liukuluvun muistiosoite. */
double **ppluku;       /* osoitintyyppi: Liukuluvun osoitteen osoite */
double ***pppluku;     /* edelleen liukuluvun osoitteen osoitteen osoite */

/* jne... eli muistiosoituksen "epäsuoruuden" asteelle ei ole rajoitusta.*/

char **argv;  /* Osoitin joka osoittaa osoittimeen joka osoittaa chariin */
char *argv[]; /* Itseasiassa sama asia! Taulukot ovat muistiosoitteita.. */

Huomaa, että tyypin syntaksissa osoitin merkitään tähdellä *, joka edeltää välittömästi sen muuttujan nimeä, josta halutaan tehdä osoitin eikä arvo. Se kannattaa kirjoittaa C:ssä kiinni muuttujan nimeen, vaikka olisi mahdollista kirjoittaa se tyypin nimeen. Syy on seuraava C-kielen kommervenkki:

double  a, b;    /* Määritelty kaksi liukulukua arvoina */
double *c, *d;   /* Määritelty kaksi osoitinta liukulukuihin */
double  e, *f;   /* e on arvo, f on osoitin. */
double* g, h;    /* g on osoitin, h on arvo!! Vaara on tässä.*/
double  *i, j;   /* Tämä on toivottavasti selkeämpi kirjoitusasu! */

Huomaa, että vaikka kaikki osoittimet ovat samanlaisia (eli muistiosoitteita, vähintään osoiteavaruuden bittimäärän kokoisia bittijonoja), ne ovat C-kielessä tarkkaan tyypitettyjä: double-osoittimella ei voi osoittaa vahingossa esimerkiksi int-muuttujaan (Tahallaan ja tositarkoituksella voi, eräällä syntaksilla, jota tässä ei käsitellä! Mutta jälleen käyttötarkoitukset ovat harvassa ja yleensä sellaisia vältellään. C:ssä ohjelmoijalla on täysi kontrolli kaikesta ja vastuu oikeellisen ohjelman tekemisestä. Turvaverkot ovat minimaalisia; kääntäjän varoitukset ovat yksi sellainen. Paras ohjelma suunnitellaan tietysti niin, että turvalliselta ja vahvasti tyypitetyltä keskitieltä ei tarvitse normaaliolosuhteissa juurikaan poiketa)

Osoitinaritmetiikka

C:ssä on toteutettu ns. osoitinaritmetiikka eli osoittimeen voidaan lisätä ja vähentää lukumääriä: Muistiosoitteethan ovat tyypillisesti aina yhden tavun (8 bittiä) osoitteita, joten esimerkiksi, jos taulukossa on 4 tavun mittaisia eli 32-bittisiä lukuja, niin aina seuraavan alkion osoittamiseksi pitäisi lisätä muistiosoitteeseen nelonen. Taas jos taulukossa on 64-bittisiä asioita, niin pitäisi lisätä kahdeksan. Taas jos taulukossa on 1234 tavun mittaisia merkkijonopuskureita tai muita datarakenteita, niin ilmeisesti pitää lisätä 1234 että osoitin päätyy osoittamaan seuraavaa alkiota. Koska C-kääntäjä tietää, minkä tyyppiseen (ja minkä mittaiseen) asiaan osoitinmuuttuja osoittaa, niin se osaa tulkita esimerkiksi koodin osoitin++ tai osoitin = osoitin + 1 siten että jatkossa osoitetaan seuraavan samantyyppisen alkion ensimmäistä tavua; muistiosoitteeseen ei siis lisätä ykköstä vaan osoitetun tyypin mukaisen arvon koko tavuissa. Mieti läpi ja piirrä ruutupaperille tarvittaessa.

Taulukon toteutus C:ssä

Uudemmissa oliokielissä taulukot ovat käytön kannalta tavallisia olioita (jonkin Object -yliluokan rajapinta on käytössä), vaikka niille on kääntäjän tasolla rakennettu "syntaksisokeria" ja kaiketi tehokas sisäinen toteutustapa. Silloin on olio-ohjelmoinnin kaikki mukavuudet ja herkut käytössä, mm. taulukon pituuden saa attribuutista tai saantimetodista taulukko.length jne. C-kielessä mitään tällaista ei ole -- olioabstraktiota kun ei tueta tämän yksinkertaisen ja laiteläheisen kielen määrittelemillä ominaisuuksilla.

C-kielessä taulukko on muistialue, joka sijaitsee peräkkäisissä, yhden alkion mittaisissa muistipaikoissa (varaustavasta riippuen prosessin pinossa, data-alueella tai dynaamisella alueella eli "keossa"). Mitään muuta se ei ole. Viiden 64-bittisen luvun taulukko nimeltä taul olisi seuraavanlainen:

.                  +-------------------+
Muistipaikka  N+48 | Jotain ihan muuta!|
                   |                   |
Muistipaikka  N+40 |                   |
                   +-------------------+
Muistipaikka  N+32 | taul[4] (8 tavua) |
                   +-------------------+
Muistipaikka  N+24 | taul[3] (8 tavua) |
                   +-------------------+
Muistipaikka  N+16 | taul[2] (8 tavua) |
                   +-------------------+
Muistipaikka  N+8  | taul[1] (8 tavua) |
                   +-------------------+
Muistipaikka  N    | taul[0] (8 tavua) |
                   +-------------------+
Muistipaikka  N-8  | Jotain ihan muuta!|
                   |                   |
Muistipaikka  N-16 |                   |
                   +-------------------+

HUOM: Tässä muistipaikan numero N == taul == &taul[0]
      eli C:ssä taulukkomuuttuja ja osoitin ensimmäiseen
      alkioon ovat samaistettavissa.

Mitään muuta taulukko ei C:ssä ole kuin varattua muistia. Taulukkoon viitataan aina tavallaan muistiosoitteen avulla. Syntaksi vain näyttää kätevältä:

oso = taul;      /* oso saisi arvokseen alkion taul[0] muistiosoitteen! */

aa  = taul[2];   /* Kääntäjä laskisi valmiiksi alkion taul[2]
                    muistipaikan, ja aa saisi siellä sijaitsevan
                    lukuarvon */

paa = &taul[2]   /* Tässä kääntäjä laskisi alkion taul[2] muistipaikan,
                    ja nimenomaan se muistipaikka eli osoite
                    laitettaisiin muuttujaan paa, jonka pitäisi
                    olla tyypiltään osoitin samantyyppiseen tietoon,
                    kuin mitä taulukko sisältää */

hmm = taul + 2;  /* "Osoitinaritmetiikan" vuoksi sama kuin edellinen. */

HUOMAA: Kukaan ei kerro ajonaikaisesti, minkä verran taulukolle on varattu muistista tilaa! Taulukon käyttö C-kielessä edellyttää aina sitä, että ohjelmoija pitää ihan itse kirjaa taulukoiden koosta jossain muuttujassa tai vakiossa. Esimerkiksi aina kun aliohjelmalle annetaan parametrina taulukko (eli ensimmäisen alkion muistiosoite), se tarvitsee tavalla tai toisella tiedon myös siitä, mihin taulukko päättyy, eli missä on viimeinen käsiteltävä alkio. Helpoimmillaan tämän voi antaa erillisenä kokonaislukutyyppisenä parametrina, johon laitetaan taulukon alkioiden lukumäärä.

Merkkijonon toteutus C:ssä

C#:ssa ja Javassa merkkijonot voidaan tehdä esimerkiksi String tai StringBuffer -luokkien instansseina, mitä niiden nimet nyt kussakin kielessä sattuvat olemaan. Tässäkin on olio-ohjelmoinnin mukavuudet ja herkut käytössä: mm. merkkijonon pituuden saa tietää olion rajapinnan kautta, esimerkiksi Javassa metodilla merkkijono.length() ja C#:ssa ominaisuudella merkkijono.Length. Muuttuvaisella jonolla eli StringBuffer tai StringBuilder -luokan oliolla on mahdollisuus pidentyä ja lyhentyä mielivaltaisia määriä metodien suorituksen yhteydessä. C-kielessä mitään tällaista ei ole - ei ole olioita rajapintoineen.

C-kielessä merkkijono sijaitsee taulukossa, johon on varattu tilaa ``char``-tyyppisille muuttujille vähintään merkkijonon merkkien verran plus yksi. Plus yksi sen takia, että C:ssä merkkijonon loppu pitää ilmoittaa "nollamerkillä", siis char-tyyppisellä kokonaisluvulla 0. Tästä voi nähdä esimerkin C:llä tehdyn "Hei maailma" -sovelluksen tavujonoa tutkimalla. Esimerkiksi merkkijonon "Au" sijoittuminen muistiin voisi olla seuraavanlainen:

.                  +-------------------+
Muistipaikka  N+6  | Jotain ihan muuta!|
                   |                   |
Muistipaikka  N+5  |                   |
                   +-------------------+
Muistipaikka  N+4  | jono[4] 'x'       |
                   +-------------------+
Muistipaikka  N+3  | jono[3] 'y'       |
                   +-------------------+
Muistipaikka  N+2  | jono[2] '\0' eli 0|
                   +-------------------+
Muistipaikka  N+1  | jono[1] 'u'       |
                   +-------------------+
Muistipaikka  N    | jono[0] 'A'       |
                   +-------------------+
Muistipaikka  N-1  | Jotain ihan muuta!|
                   |                   |
Muistipaikka  N-2  |                   |
                   +-------------------+

Eli merkkijonon alku on jossain muistipaikassa ja merkkijonolle on varattu tilaa viiden yksitavuisen merkin verran (yksi tavu riittää ASCII- tai UTF-8 -koodatuille ameriikan aakkosille; ääkkösten ja muiden maailman kielien käsittely on monimutkaisempaa, joten ohitetaan se suosiolla tässä johdannossa). Koska jono on "Au" eli siinä on merkit 'A' ja 'u', ne ovat vastaavissa paikoissa taulukkoa. Niiden jälkeen on nolla, joka kertoo, että jono päättyy siihen. Muilla taulukon arvoilla ei ole merkitystä, koska niitä ei tulkita kuuluvaksi merkkijonoon enää nollamerkin jälkeen, vaikka ne mahtuvat merkkijonolle varattuun muistialueeseen. Merkit 'x' ja 'y' voisivat yhtä hyvin olla lukuarvot 123 ja 234 tai ihan mitä vaan. Ei väliä; merkityksellinen merkkijono loppui jo merkkiin, joka tuli viimeisenä ennen nollaa.

Sanotaan, että varattu muistitila on merkkijonopuskuri (string buffer), johon mahtuu nollamerkkikoodauksen takia korkeintaan "puskurin koko - 1" merkkiä pitkä jono.

Vaihtuvankokoisten merkkijonojen käyttö C-kielessä edellyttää erillisen apukirjaston tekemistä, joka hoitaa mm. isompien puskurimuistien varaamista dynaamisesti, mikäli puskuriin ollaan lukemassa enemmän kuin nykyiseen tilaan mahtuu.

Tietoturvaan liittyvä välihuomautus

Seuraava huomautus on välttämätöntä ja toisaalta hyvin soveliasta tehdä tässä vaiheessa, vaikka tietoturvaan liittyvät seikat käydäänkin tarkemmin muilla kursseilla. Kyseessä on yksi hyvä esimerkki havaituista käytännön ongelmista, joita voidaan osittain yrittää ratkaista uusilla teknologisilla ja ohjelmistollisilla keinoilla. Loppupäätelmänä on kuitenkin, että ainakin näinä päivinä tietoturva on vielä paljolti ohjelmistojen tekijöiden vastuulla.

Jos vahingossa merkkijonopuskuriin sijoitettaisiin esimerkiksi käyttäjän syötteestä enemmän merkkejä kuin sinne mahtuu, olisi kyseessä "puskurin ohikirjoitus" eli Buffer overrun/overflow, joka on historiallisesti erittäin suosittu ja helppo tapa murtautua tietojärjestelmiin -- jos kirjoitetaan merkkejä (eli tavuja) sopivasti yli puskurialueen, saatetaan päästä kirjoittamaan niitä jopa muistiosoitteeseen, jossa olisi suoritettavaa koodia. Sinne voisi kirjoittaa sopivilla merkeillä mitä tahansa konekieltä ja saada tietokone tekemään ihan mitä itse haluaa. Vertaa luentoesimerkkiin, jossa merkkijono "Hello, hello, hello!" sijaitsee juuri ennen suoritettavaa ohjelmakoodia. Merkkijonon tavujen (ja nollamerkin) perässä ovat suoritettavan konekielisen ohjelmakoodin tavut. Entä jos ohjelma pelkän tulostamisen lisäksi lukisikin käyttäjältä jonkun oman tervehdystekstin kyseisen oletustervehdyksen tilalle? Voi rähmä, jos käyttäjän syötteestä vahingossa luettaisiin tavuja yli merkkijonolle varatun tilan - suoritettava koodi muuttuisi tietenkin ihan miksi vain tuo kyseinen käyttäjä haluaisi. Eihän hän voi arvata, että ohjelmassani on sellainen aukko? Ei kun kyllä hän vaan tosiaankin tietää, jos hän tutkii ohjelmani toimintaa vaikka disassemblynä ja haluaa käyttää riittävästi aikaa löytääkseen sieltä käyttökelpoisen tietoturva-aukon!

Tällainen hyökkäys onnistuu ihan vain vastaamalla ohjelman kysymykseen "Who are you?", jos sen tekijä ei osannut ohjelmoida C:llä turvallisesti! Onneksi prosessorien muistinsuojaus nykyään jonkun verran auttaa... ajettavan koodialueen tai vakiomerkkijonoja sisältävän data-alueen virtuaaliosoitteet voivat olla kirjoitussuojattuja, jolloin ohjelma kaatuu ns. suojausvirheeseen, jos joku konekielikäsky yrittää sijoittaa koodialueelle. Toista oli ennenvanhaan, kun suojaus prosessori- ja käyttöjärjestelmäteknologian puolesta oli alkeellisempaa. Vielä ihan muutama vuosi sitten erään sinänsä sangen hyödyllisen ja yleisesti tunnetun pilvipalvelun asiakasohjelmisto vaati toimiakseen pinoalueen suoritusoikeutta... asiaa vähänkään tuntevalle tällainen on aivan järkeenkäymätön mörkö. Onneksi myös kyseinen softa on sittemmin päivitetty tältä osin järkevämmäksi.

Varovaisuudesta ei saa ikinä tinkiä: C-ohjelman, joka lukee merkkejä yhtään mistään, TÄYTYY olla toteutettu siten, että varattu puskurialue ei missään nimessä ylity! Mitä tämä taas edellyttää? Sitä, että ohjelmoija pitää kirjaa puskurille varatusta tilasta, vaikkapa jossakin muuttujassa, ja käyttää sellaisia algoritmeja, jotka hyödyntävät tuon tiedon. Sama asia siis kuin muidenkin taulukoiden yhteydessä.

Olisiko ratkaisu olla käyttämättä vanhaa ja vaaralliseksi todettua C-kieltä ja käyttää sen sijaan jotakin uudempaa virtuaalikoneen päällä toimivaa tai tulkattavaa kieltä? ... Yksi vastaus löytyy lukemalla uutisia näiden uudempien alustojen havaituista tietoturvaongelmista ja ekstrapoloimalla jonkinlainen arvio toistaiseksi havaitsemattomien ongelmien määrästä. Sovellusohjelmoijan ainoa oikea johtopäätös käytetystä kielestä tai alustasta riippumatta on: Varovaisuudesta ei saa ikinä tinkiä. Mieti mitä teet, tiedä mitä teet, tiedä mitä satunnainen käyttäjä pystyy halutessaan tekemään. Ja jos sillä on oikeasti väliä, varmista että lisäksi vielä joku muu validoi sen mitä olet tehnyt. Joissain sovelluksissa, kuten siviililentokoneita ohjaavien ohjelmistojen tekemisessä, on erittäin tarkkaan standardoidut menettelyt ohjelmakoodin laadunvarmistukseen. Ikävä kyllä tavallisten ei-lentävien softien tekemisessä ei ole mitään globaalisti valvottua laatustandardia, vaan siellä ollaan aikalailla omalla ja firman vastuulla liikenteessä.

Tervemenoa jatkokursseille Tietoverkkoturvallisuus, Ohjelmistoturvallisuus ja muu Kyberturvallisuuden maisterikoulutuksen kurssitarjonta, mikäli aihepiiri alkaa kiinnostaa enemmän. (Kaksi nimeltä mainittua kurssia kuuluvat suurimpaan osaan tietotekniikan nykyisistä maisterikoulutuksista -- eivät ole huonoja kursseja muillekaan alan toimijoiksi haluaville.)

Muita tyyppejä

C:ssä on pari muutakin eksoottista tyyppiä, jotka jätetään opiskeltavaksi tarkemmin muusta lähteestä tarvittaessa:

  • enum eli lueteltu tyyppi:

    enum {APPELSIINI, OMENA, PAARYNA};
    

    määrittelisi lukuarvot APPELSIINI==1, OMENA==2, PAARYNA==3; voi käyttää esimerkiksi vaihtuvien tilojen koodaamiseen.

  • union tyyppi, joka itse asiassa vaihtaa tyyppiä sijoituksen mukaan; varmaan aiheuttanut monta sekaannusta ja vaikeaselkoisuutta ajan mittaan, veikkaan. On tämä joskus näppäräkin, mutta ehkä ihan syystä jätetty pois myöhemmistä, muutoin C:n pohjalta määritellyistä, kielistä, kuten C# ja Java.

Dynaaminen muistinvaraus

C#:ssa tai Javassa aina, kun syntyy olio new -operaattorin toimesta, olion datalle varataan tilaa kekomuistista. Tila vapautetaan automaattisesti roskien keruun yhteydessä jossain vaiheessa sen jälkeen, kun mistään ei ole enää viitettä olioinstanssiin. Kielen ajonaikainen ympäristö pitää kirjaa viitteistä, eli konepellin alla tarvitaan tilaa olion oman datan lisäksi myös kirjanpitotiedolle.

C:ssäkin on mahdollista varata tilaa tietorakenteille dynaamisesti eli aina tarvittaessa. Toisin sanoen on erittäin hyvin mahdollista tehdä dynaamisesti kasvavia ja pieneneviä tietorakenteita, ihan niinkuin oliokielten ''säilöluokat'', kuten linkitetyt listat, toimivat. Tilanvaraus pitää tehdä esimerkiksi muistinvarauskutsulla malloc() tai calloc(). Arvatenkin, kuten C:ssä yleensä, ohjelmoija saa käyttöönsä vain osoittimen varatun tilan alkuun, eikä kukaan muu pidä kirjaa muistialueiden vapauttamisesta. Ei ole valmiina roskienkeruuta eikä kirjanpitoa osoittimien viittauksista. C-ohjelmoinnissa on helppo saada aikaan muistivuoto eli hankalasti havaittava ongelma, jossa ohjelma varaa koko ajan lisää ja lisää muistia eikä koskaan vapauta sitä. Muistin täyttyminen monen käyttäjän järjestelmässä on sen verran ikävä ilmiö, että jätettäköön malloc / calloc -kutsujen harjoittelu omalla kotikoneella itsenäisesti tehtäväksi.

"Hosoittaminen" minne sattuu

Muistivuodon lisäksi C:ssä on helppo saada aikaan irrallinen osoitin (dangling pointer), joka on jossain vaiheessa ollut tarpeellinen muistiosoite, mutta jonka osoittama data on aikaa sitten lakannut olemasta mitenkään relevantti. Kyseessä on aina ohjelmoijan huolimattomuus -- hän ei ole pitänyt kirjaa osoituksistaan vaan niistä on tullut "hosoituksia". Helposti tämä käy joko dynaamisesti varattavien ja poistettavien alueiden kanssa tai esimerkiksi "lasten C-virheellä", jossa varataan lokaali taulukko ja kuvitellaan että sen voisi palauttaa aliohjelmasta. Kun pinokehyksen käsite ja aliohjelman suoritusperiaate konekielitasolla tulee selväksi, tiedät, mikä tässä on väärin:

int *tee_taulu(){
    int tmp[100];
    return tmp;     /* -Ups- */
}

Juonipaljastuksena voin kertoa, että sadan kokonaisluvun taulukko, jonka ensimmäisen alkion ensimmäiseen tavuun tmp tulee osoittamaan, varataan pinokehyksestä, joka aina vapautetaan aliohjelman päättyessä myöhempien aliohjelmakutsujen käyttöön. Mikään lokaali muuttuja ei elä aliohjelman lopun jälkeen; siksi niiden nimikin on lokaali eli paikallinen... Taulukon merkitys unohtuu, mutta sen väliaikaisluonteinen muistiosoite palautetaan kutsuvalle aliohjelmalle. Toiminnallista tulosta ei voi ennustaa. Oikeasti ilmeisesti oli tarkoitus tehdä dynaaminen varaus ja palauttaa osoitin dynaamisesti varattuun, uuteen, kekomuistialueella sijaitsevaan tilaan.

Virhe voi tulla helposti nykyisiin oliokieliin tottuneelle, koska taulukot, merkkijonot ja kaikki ovat niissä aina olioita, jotka luodaan ei-lokaalisti kekoon, ja lokaali viite voidaan kyllä palauttaa returnilla, eikä olio muutu roskaksi, mikäli viite menee kutsujalla talteen.

Täydellisyyden vuoksi lienee syytä kirjoittaa oikeellinen versio, jossa taulukon tila varataan dynaamisesti keosta ja "roskien keruu" on hoidettu "näppärästi" aliohjelman kommentissa siirtämällä vastuu aliohjelman kutsujalle:

/** Varaa muistia n:lle kokonaisluvulle. Varatun tilan
 *  vapauttaminen on kutsujan vastuulla.
 */

int *tee_taulu(int n){
    int *tmp;
    tmp = calloc(n,sizeof(int));  /* Sisällöksi tulee nollia. */
    if (tmp==NULL){
      fprintf(stderr,
          "Varaus epäonnistui. Muisti täynnä? Ohjelma suljetaan.");
      exit(1);      /* Parempi "kaataa" heti kuin myöhemmin! */
    }
    return tmp;     /* So far so good; luotetaan että soveltaja on
                     * lukenut rajapinnan dokumentaation eli tuon
                     * kommentin, jonka mukaan hänen oma
                     * ohjelmansa on vastuussa tämän jälkeen.
                     */
}

Kutsun tekijä voi (ja hänen pitäisi) vapauttaa varattu tila kutsumalla sopivaksi katsomassaan vaiheessa free(muistiosoitin);, missä hän on pitänyt osoitetta tallessa osoitinmuuttujassa. Kutsu on voinut olla esimerkiksi int *muistiosoitin = tee_taulu(10000);.

Kontrollirakenteet C:ssä

Kontrollirakenteet ovat C:ssä hyvin samanlaisia kuin C#:ssa ja Javassa, esimerkkejä alla. Jos syntaksi toimii C#:ssa/Javassa jollain tavoin, se varmaan toimii C:ssä hyvin samankaltaisesti ja toisin päin. Paitsi hieman edistyneempi "foreach"-rakenne, jolle ei ole vastinetta C:ssä.

Ehtolause:

if (ehto) {
    ... jotain ...
} else if (ehto2) {
    ... jotain muuta ...
} else {
    ... vielä jotain ...
}

Silmukoita:

for (alkuasetus; jatkamisehto; päivitystapa) {
  ... jotain ...
}

while (ehto) {... jotain ...}

do {... jotain ...} while (ehto)

Switch-lause:

switch(merkki)
   {
      case 'A' :
        printf("Aaa");
        break;            /* tärkeä! Muuten "putoaa läpi" seuraavaan */
      case 'B' :
        printf("Bee");
        break;
      case 'C' :
        printf("See");
        break;
      case 'D' :
        printf("katellaas..." );
      case 'E' :
      case 'F' :
        printf("Dee tai Eee tai Eff");
        break;
      default  :
        printf( "Ei ollu A,B,C,D,E eikä F");
   }

Aliohjelmakutsu (vrt. metodikutsu):

tulos = aliohjelma(param1, param2, param3);

Olihan niitä rakenteita varmaan muitakin... yleensä C:ssä toimivat samat perusrakenteet kuin sen syntaksia jäljittelevissä C#:ssa ja Javassa.

Mitä ohjelmointi on, kun ei ole olioita

C:ssä ei siis ole ''olioita''. Eli mitä tämä tarkoittaa:

  • Tietorakenteita ja niitä käsitteleviä algoritmeja ei voi yhdistää samaan pakettiin, jota sanottaisiin luokaksi. On vain yksittäisiä arvoja, tietueita eli struktuureita, taulukoita ja muita yksinkertaisia datapötköjä. Niiden lisäksi on aliohjelmia, joille voi antaa dataa käsiteltäväksi. Useimmiten soveltuvinta on antaa datat osoittimina, jolloin kutsuttavat aliohjelmat voivat muuttaa kutsuvan aliohjelman dataa.

    Tämähän vastaa läheisesti sitä, että annetaan oliokielessä olion viite jollekin metodille, joka voi käsitellä parametrina saadun olion tilaa. Itse asiassa oliokielissä viite "self" tai "this" -olioon menee implisiittisesti metodin käyttöön ja tämän kaltaisilla nimillä sitä voidaan oliokielissä myös käytellä. Koska se on aina mukana, monien kielten määrittelyssä this-viiteen on sovittu välittyvän automaattisesti. Esimerkiksi Python-kieli (ainakin versio 2, jonka tämän demon kirjoittaja tuntee) on poikkeus tähän sääntöön: siinä kaikkien instanssimetodien ensimmäisen parametrin pitää olla eksplisiittisesti viite olioon, johon metodin toimenpiteiden halutaan kohdistuvan.

  • Aiemmin määriteltyjä tietorakenteita/algoritmeja ei voi laajentaa perimällä.

  • Tietorakenteiden sisäistä toteutusta ei voi pakotetusti piilottaa soveltajalta; kaikkeen on mahdollista päästä sorkkimaan rajapinnan ohi.

  • (ja ei ole varsinaisia poikkeusluokkia ja try-catch -tyyppistä poikkeuskäsittelyä jne...)

Toki ei ole mitään, mitä oliokielellä voisi tehdä mutta C:llä ei. Kummallakin voi ratkaista minkä tahansa tehtävän, jonka tietokoneella ylipäätään voi. Kyse on vain toteutuksen helppoudesta. Loppujen lopuksi kaikki palautuu kaikissa kielissä siihen, että prosessori suorittaa prosessin konekielistä käskyjonoa yksi käsky kerrallaan nouto-suoritus -syklinsä mukaisesti, sanottiinpa tuota käskysarjaa sitten aliohjelmaksi, proseduuriksi, metodiksi tai funktioksi. Tämä asia toivottavasti on yksi, joka iskostuu mieleen Käyttöjärjestelmät -kurssilta. Kielijärjestelmät kehittyvät suuntaan, jossa ne ovat ihmiselle helpompia, ja kone siellä taustalla kehittyy nopeammaksi, mutta varsinaiset ratkaistavissa olevat tehtävät pysyvät yhtä rajallisina nykyisenkaltaisen yli 70 vuotta vanhan teknologian vallitessa. Saa nähdä, miten esimerkiksi kvanttitietokoneet muuttavat kuviota tulevaisuudessa, ehkä piankin.

Pakollinen palautustehtävä

Keväällä 2022 läsnäoloversio merkitään tehdyksi, kun olet käynyt mikroluokassa kahden tunnin ryhmätyösessiossa. Älä häviä luokasta vähin äänin ennen kuin läsnäolot merkitään!

Itsenäinen palautustapa:

Normaaliin tapaan pääteyhteydellä ja screenillä näppärästi valmistele hakemisto, jossa aiot tehdä tämän demon harjoitukset, esimerkiksi ~/kj22/demo4/. Hae demon 4 mallikoodi seuraavalla komennolla suorakäyttökoneella:

wget https://gitlab.jyu.fi/itka203-kurssimateriaali/itka203-kurssimateriaali-avoin/-/raw/master/clabra/esimerkki2.c

Sitten toimi C-koodin kommenttien mukaisesti. Palautettavaa on "teoriaosuus" muokkauksina pitkään alkukommenttiin ja "käytännön osuus" siten kuin kommentin ohjeissa vaaditaan. Palautus on tasan yksi C-lähdekooditiedosto, jossa nämä on tehty. Ne ovat samat asiat, jotka läsnäoloryhmissä tehtiin ja keskusteltiin porukalla.

Lisää ohjeita on C-mallikoodin kommenteissa.

Huomaa, että palautetun C-ohjelman tulee tehdä ja tulostaa samalla periaatteella samanlainen lukujono kuin mitä koodikommentista linkitetty C#-esimerkki tekee! Hylsy tulee heti, jos ei se sitä tee!