Esikääntäjä ja vakiot

 

Esikääntäjän perustemput

Esikääntäjä on kuin esipesu pyykkikoneissa; se tapahtuu ennen varsinaista huljuuttelua. Haa, puhun vertauksilla ihan kuin Jeesus konsanaan. Esikääntäjä on siis ohjelma, joka käsittelee lähdekoodia ennen kuin oikea kääntäjä saa sen hyppysiinsä. Esikääntäjää ohjataan #-alkuisilla käskyillä ja niiden loppuun ei kirjoiteta puolipistettä. Hyvin yleinen esikääntäjälle annettu käsky on:

#include <iostream.h>

Alussa on #, kuten pitääkin. Sitten include, siis sisällytä. Niinpä tuohon kohtaan esikääntäjä asettelee tiedoston iostream.h ja kun tiedosto syötetään oikealle kääntäjälle, niin iostream.h:n sisältö on rivin kohdalla. #-alkuiset käskyt eivät ole enää mukana kääntäjälle menevässä versiossa. Sisällytettävän tiedoston nimi kirjoitetaan <>-merkkien väliin. Tämä tarkoittaa, että se löytyy siitä hakemistosta missä kääntäjän mukana tulevat otsikkotiedostot (.h) ovat, yleensä hakemisto on kääntäjän hakemiston alihakemisto INCLUDE tai H. Tiedoston nimi voidaan kirjoittaa myös lainausmerkkeihin, mikä tarkoittaa, että tiedosto löytyy samasta hakemistosta lähdekoodin kanssa. Tätä siis yleensä käytetään omien otsikkotiedostojen sisällyttämiseen, kun taas <>-merkkejä kääntäjän otsikkotiedostoihin.

Toinen usein käytetty esikääntäjän kikka on vakioiden määritteleminen. Jos ohjelma piirtää 320x200 tarkkuudella olevaa grafiikkaa, pitää resoluutiot 320 ja 200 kirjoittaa usein koodin sekaan. Kun tulee aika päivittää ohjelmaa ja laittaa se toimimaan tilassa 640x480, tulee pirunmoinen urakka vaihtaa jok'ikinen luku. Hyvin todennäköisesti joku arvo jää vaihtamatta, jolloin homma ei toimi tai pahemmassa tapauksessa ohjelma toimii mystisellä tavalla väärin ja se ei valmistu ikinä ja ohjelmistofirma menee nurin jne..

Kaiken tämän kurjuuden voi välttää määrittelemällä vakion X_RES, siis horisontaaliresoluutio, joka on 320. Se tehdään esikääntäjän avulla näin näppärästi:

#define X_RES 320
#define Y_RES 200

Sitten voimme käyttää sanoja X_RES ja Y_RES aivan kuin ne olisivat lukuja. Kun muutamme tarkkuutta, pitää se muuttaa vain #define-lauseista. Kätevää, eikö olekin? Kun esikääntäjä saa define-käskyn, se plaraa lähdekoodin läpi ja korvaa kaikki X_RES-sanan ilmentymät arvolla 320. Tämä koskee tietenkin vain kokonaisia sanoja, X_RESOLUTION_DETECT ei muutu 320OLUTION_DETECT:ksi. Esikääntäjä ei myöskään käsittele kommentteja tai lainausmerkkien sisällä olevia pätkiä, kuten coutin tulostuksia.

 

Vakiot oikeaoppisesti

Olen varmaan jo maininnut muutaman kerran, että esikääntäjä on kääntäjä, joka käsittelee lähdekoodin ennen kääntäjää. Eli esikääntäjä käsittelee vakiot ja hävittää niiden jäljet, jolloin oikealla kääntäjällä ei ole mitään tietoa mitä tyyppiä eri vakioarvot ovat. Kääntäjä ei siis voi tarkistaa arvojen tyyppiä. Sehän ei käy päinsä C++-kielessä, jossa muuttujien tyyppitarkastus on viety hyvin pitkälle. Pieni esimerkki:

#define ARVO 1280
unsigned char tavu = ARVO;

Mikäkö tässä mättää? unsigned charin arvoalue on 0..255, joten 1280:n sijoittaminen siihen ei onnistu. Tarkoitus oli laittaa ARVOksi 128, joka olisi ollut oikein, mutta huolimaton ohjelmoija painoi vahingossa yhden nollan liikaa (kertomus perustuu täysin tositapahtumiin). Kääntäjä ei tiedä mitä tyyppiä ARVOn pitäisi olla, eikä se osaa varoittaa siitä, että sille annetaan väärän tyyppinen arvo. Niinpä onkin järkevämpää käyttää vakioiden määrittelemiseen esikääntäjän sijasta oikeaa kääntäjää, jolloin se voi valvoa myös vakioiden oikeaoppista käyttöä. Niin saamme taas yhden vastuun siirrettyä kääntäjälle, joka tekee sen meitä nopeammin, huolellisemmin eikä kyllästy hommaansa saati sitten vaadi palkkaa.

Kääntäjän vakiot määritellään const-määreen avulla. Sen jälkeen tulee ihan tavallinen muuttujan määrittely. Ero on kuitenkin se, ettei const-muuttujan arvoa luonnin jälkeen voi muuttaa.

const unsigned char ARVO = 1280;
unsigned char tavu = ARVO;

Nyt kääntäjä osaa heti huomauttaa siitä, että unsigned char-tyyppiseen muuttujaan ei voi sijoittaa arvoa 1280 ja selviämme paljon vähemmällä virheen jahtaamisella ja itkuvirsien luikuttelulla.

 

typedef

Hyvin yleinen villitys on tämänlainen tapa määritellä tietotyypeille käteviä lyhenteitä:

#define byte unsigned char // määritellään byte vastaamaan unsigned charia

Tässä on vaan taas se kommervenkki, että esikääntäjä on vain typerä merkkijonojen korvaaja - eikä siis pysty millään lailla valvomaan määrittelyjen käyttöä. Parempi on taas antaa kääntäjän hoitaa tämä tyypin määrittely, siis type definition eli lyhyemmin typedef:

typedef unsigned char byte;

Huomaa, että järjestys määrittelyssä on nyt toisinpäin ja lopussa on puolipiste, koska koodi menee kääntäjälle. Nyt voimme käyttää unsigned char -tyypin tilalla peitenimeä byte. Se nopeuttaa ohjelman kirjoittamista ja tekee siitä helpommin luettavan. Koska tietotyypit ovat ohjelmointiympäristökohtaisia, voimme myös peitenimien avulla peitellä tätä ongelmaa - käytämme vain peitetyyppiä, ja eri ympäristöissä määrittelemme sen vastaamaan eri tietotyyppejä.

 

Lueteltu tyyppi: enum

enum on semmoinen kätevä (?) juttu, jota voi käyttää numeroin koodatun tiedon säilyttämiseen. "Kätevä"-sanan perään lykkäsin kysymysmerkin, koska en itse ole enumia juuri koskaan käyttänyt mihinkään. Mutta kun nyt tarkemmin ajattelen, niin ehkä vika sittenkin on minussa. Jospa pyrin muuttamaan tapojani, niin suhde minun ja enumin välillä voi vielä kehittyä antavaksi ja hedelmälliseksi. Mikäs paholaisen keksintö se enum sitten on? No, sehän lueteltu tyyppi, siis enumerated type, mistä nimikin on varmaan keksitty. Sillä voi kätevästi esittää jotain tietoa, joka on koodattu numeroarvoin: siis esimerkiksi viikonpäiviä esittävässä muuttujassa on maanantai koodattu viikon ensimmäiseksi päiväksi, siis C++:ssa luvuksi nolla (indeksointi alkaa nollasta C++:ssa, kuten varmasti muistatkin). Eli käytännössä:

enum PAIVA {MA, TI, KE, TO, PE, LA, SU };

Noin se toimii. Luodaan uusi tietotyyppi PAIVA, jolla on seitsemän sallittua arvoa MA, TI, KE, TO, PE, LA ja SU. Muuttuja on itseasiassa int-luku, jolla on arvot 0 - 6. Siis vertailu TI < TO tarkoittaa samaa kuin 1 < 3, siis tosi. Tiistai on ennen torstaita. Jotkut kääntäjät antavat sijoittaa lueteltuun tyyppiin myös numeroarvon, siis viikonpaiva=2 ja viikonpaiva=KE ovat sama sijoitus. Yleensä kuitenkin kääntäjät murahtavat tällaisesta sijoituksesta - ja aiheesta. enumin idea on juuri päästä eroon paljaiden lukujen käytöstä. Huomaa myös sellainen seikka, että vakionimet (siis MA, TI... edellisessä esimerkissä) ovat vain kääntäjän ymmärtämiä peitteitä lukuarvoille - ja kun kääntäjä sylkäisee valmiin ohjelman sisuksistaan, ei merkkijonoja enää ole. Sijoitus PAIVA="MA" ei onnistu, koska "MA" on merkkijono eikä luvun 0 peitenimi. Vakionimille voi antaa myös itse numeroarvot tyypin luonnin yhteydessä.

enum TURVALLISUUSLUOKITUS { VIERAS = 0, PERUSKAYTTAJA = 2, TEHOKAYTTAJA = 4, YLLAPITAJA = 10 };

int main()
{
	TURVALLISUUSLUOKITUS jaska;
	jaska = PERUSKAYTTAJA;
	
	cout << "Jaskan luokitus on " << jaska << endl;
	if (jaska > VIERAS) cout << "Jaskalle voi paljastaa vähän salaisempiakin tietoja.." << endl;
	
	return EXIT_SUCCESS;
}

 

Esikääntäjä tappaa talossa ja puutarhassa

Kukaan ei tee virheetöntä koodia, en edes minä (itseasiassa teen paljon virheita, koska virheistä oppii parhaiten ja olen innokas uuden oppija...). Kiireessä kirjoitettu lähdekoodi on bugien luonnollisinta esiintymisaluetta. Bugihan on siis virhe ohjelmassa, joka joko laittaa ohjelman toimimaan väärin tai kaatumaan kokonaan, yleensä molempia ja hieman satunnaisesti.

Bugin tappaminen voi olla joskus vaikeaa; metsästyslupia niille on hyvin helppo saada, mutta hyvän suojavärin omaavan bugin löytäminen on pirullisen vaikeaa. Joskus virheitä etsittäessä tulee mieleen, että olisipa kiva tietää millaisia arvoja tuo muuttuja saa. Senhän selvittäminen onnistuu yksinkertaisesti coutilla. Kun koodi on ripoteltu täyteen tarkistuksia ja virhe löytyy, pitää ne kaikki napsia käsin pois. Ja sitten huomataankin, että ohjelma ei ihan vielä toimikaan ja tarkistukset pitää laittaa takaisin.

On olemassa kuitenkin helpompikin keino. Valitsemme sopivan sanan, esimerkiksi DEBUG, jolla kerromme milloin käännämme ohjelmakoodia tarkistustilassa, jolloin kaikki ylimääräinen tarkistustauhka tulee mukaan. Kun ohjelma on valmis, otamme DEBUGin määrittelyn pois ja esikääntäjä karsii kaiken ylimääräisen pois meidän puolestamme.

#include iostream.h

#define DEBUG

int main()
{
	int muuttuja = 0;
	cin >> muuttuja;

#ifdef DEBUG
	cout << "muuttuja " << muuttuja <<endl;
#endif

	return EXIT_SUCCESS;
}

#ifdef, siis if defined (jos määritelty) ottaa mukaansa jäljessä olevan koodin #endifiin asti, jos pyydetty sana on määritelty. Ja sehän on, ihan alussa. DEBUGille ei anneta mitään arvoa, se on vaan määritelty. Jos heitämme kommenttimerkit ensimmäisen lauseen eteen, ei DEBUGia ole määritelty ja esikääntäjä karsii #ifdefin ja #endifin väliset tarkistukset pois.

Muuttujien arvojen seuraaminen onnistuu myös huomattavasti helpommin debuggerilla, joka yleensä tulee kääntäjän mukana. Tosin onnellinen on se, joka ei ikinä joudu ilman debuggeria ohjelmoimaan. Yleensä vähän eksoottisempi ohjelmointivälineiden yhdistely takaa sen, että debuggeri ei ole käytössä tai että se ainakaan ei toimi kunnolla. KUN debuggeri toimii, se suorittaa tekemäämme ohjelmaa askel askeleelta ja antaa seurata muuttujien arvoja. Ongelmia saattaa kuitenkin tulla esimerkiksi käytettäessä erikoisempia grafiikkatiloja, kuten PC:n SVGA-tiloja. Asian voi hoitaa kommentoimalla grafiikan käyttöön liittyvät kohdat pois, mutta toisaalta senkin työn voi antaa esikääntäjän hommaksi. Tähän käytämme käskyä #ifndef, if not defined. Se on kuin #ifdef, mutta toimii toisinpäin, siis ottaa merkityn alueen mukaan jos tiettyä sanaa ei ole määritelty.

// gfxlib.h on mielikuvituksellinen grafiikkajärjestelmä, tätä esimerkkiä
// siis ei voi kääntää

#define DEBUG

#include"gfxlib.h" // grafiikkaan liittyviä juttuja

int main()
{
#ifndef DEBUG
  AsetaKummallinenNayttotila();
#endif
  for (int a=0; a < 100; a++)
  {
    TeeMuutJutut();
#ifndef DEBUG
    PiirraKuva();
#endif
  }
#ifndef DEBUG
  PalaaTekstitilaan();
#endif
  return EXIT_SUCCESS;
}

 

Makrot

Kuvitellaan että ohjelmoit tarpeettoman väkivaltaista toimintapeliä, jonka huippunopeassa grafiikkaa piirtävässä silmukassa pitää laskea jonkun muuttujan toinen potenssi useassa kohdassa. Et viitsisi kirjoittaa sitä jokaiseen kohtaan, varsinkin kun kaava saattaa muuttua. Silloin se pitäisi muuttaa käsin useaan kohtaan ja tietäähän kaikki mitä siitä tulee. Toisaalta funktion kutsuminen aiheuttaisi aivan liikaa tehoa syövää lisätyötä ja tärvelisi huippunopean silmukan. Esikääntäjä se osaa.

#define NELIO(x) (x)*(x) // NELIO-makron määrittely

int main()
{
	cout << "5+5 toiseen on " << NELIO(5+5) << endl;
	
	return EXIT_SUCCESS;
}

Otin tässä tapauksessa NELIO-makron parametriksi lausekkeen 5+5, jotta huomaisit kuinka tärkeitä sulut ovat makromäärittelyssä. Kuvitellaan, että emme käyttäisi niitä:

#define NELIO(x) x*x

Näin määritellessä kääntäjälle kohta NELIO(5) tarjoillaan muodossa 5+5*5+5, mistä lasketaan ensin tulo 5*5 ja lopulta päädytään vastaukseen 35. Elon laskuopin mukaan (5+5)² eli 10² on kuitenkin 100. Siis laskujärjestys työntää pitkät sormensa tähänkin asiaan. Kun määrittelemme makron sulkujen kera, tulee kääntäjälle menevä lause muotoon (5+5)*(5+5) = 10*10 = 100 eli ihan oikein. Koska esikääntäjä ei tunne puolipistettä rivin päättymisen merkkinä, se tulkitsee yhden tekstirivin yhdeksi ohjelmariviksi. Mikäli makro ei mahdu yhdelle tekstiriville, voidaan se jakaa monelle riville \-merkin (kenoviiva) avulla, tähän tyyliin:

#define KOKONAISENERGIA(m) (m)*\
    	((2.998e8)*(2.998e8))

Lopuksi vielä hyvin vakava varoitus. Ole todella, todella varovainen tuon yllämainitun e = mc² -kaavan kanssa. Sen vastuuttomat käyttäjät ovat hävittäneet kokonaisia kaupunkeja.

 

Elegantti vaihtoehto makroille

Makroihin kuitenkin liittyy pari ongelmaa. Debuggeri osaa harvemmin purkaa niitä, joten ohjelmakoodin suoritus askel askeleelta ei niissä kohdin onnistu - keskikokoinen ongelma. Esikääntäjä ei osaa tehdä tyyppitarkastusta - iso ongelma. Toisaalta funktion kutsuminen hidastaa aina ohjelmaa. Mutta ovela ohjelmoija tietää avoimet funktiot. Ne ovat kuin makroja, mutta kääntäjä käsittelee niitä. Siis tyypit tarkastetaan ja debuggaus sujuu sukkelasti. inline määrittelee funktion avoimeksi.

inline double KokonaisEnergia(double m) { (m)* ((2.998e8)*(2.998e8)); }

Makroilla on kuitenkin se etu, että niillä voidaan käsitellä tietoa mitä kääntäjällä ei ole - kuten ohjelmatiedoston nimi ja kyseinen rivi, senhetkinen kellonaika jne.. Tätä jujua käytämme seuraavassa kappaleessa.

 

Ei vara venettä kaada - eikä assert() toimivaa ohjelmaa

Ohjelmoija usein olettaa aika paljon. Esimerkiksi tällaisessa silmukassa:

for (int a=0; a != ylaRaja; a++) x += a;

..oletetaan, että ylaRaja tulee joskus vastaan. Jos ylaRaja on int-muuttuja, jolla on arvo -5, niin silmukka ei lopu vasta kun a on pyörähtänyt ympäri. Vähän huonohermoisempi käyttäjä voi jo kyllästyä odottelemaan, eikä se varmaan olekaan ollut tarkoitus. Varsinkin jos ohjelmoijia on monta, niin vain Heimo Huima uskaltaa olettaa tällaisia asioita - muut ohjelmoijat kun tunnetusti ovat vastuuttomia poropeukaloita. Toisaalta eihän siitä mitään tulisi, jos jokaisiin kohtaan pistettäisiin varmistuksia eri muuttujien arvoista. Ohjelmasta tulisi vain valtava kasa virheentarkistuksia. Ja mitäpä käyttäjä tekee sillä tiedolla, että muuttuja xTerminalVelocity on saanut epäkelvon arvon - avaa ohjelman binäärikoodin heksaeditoriin ja korjaa virheen, vai?

Ohjelmoijalle kuitenkin tämmoinen tieto voi olla melko arvokas. Hym, hym, hankala tilanne... Tällaisen ajatuskulun lopputuloksena ovat suuret mielet kehittäneet assert-makron. Se varmistaa, että sille annettu ehto on tosi - siis esimerkiksi että muuttujalla on sallittu arvo. assert tulee mukaan ohjelmaan vain jos sana DEBUG on määritelty. Siis julkaistavaan versioon assertit eivät tule eikä valmiin ohjelman koko kasva. Koska assert on esikääntäjän makro, se voi antaa melko tarkkaa tietoa virheestä, käyttäen hyväksi esikääntäjän määrittämiä vakioita (muotoa __VAKIO__). Kääntäjien mukana yleensä seuraa assert.h -tiedosto, josta makron määrittely löytyy. Jos sellaista ei ole, niin voidaan assert tehdä itse.

Tiedosto assert.hpp:

#ifndef _assert_hpp
#define _assert_hpp


#ifndef DEBUG
#define assert(x) // jos DEBUG ei määritelty, assert on tyhjä

#else
#define assert(x) if (! (x)) { \
        cout << "VIRHEELLINEN TILANNE!" << endl; \
        cout << "Assert epäonnistui: " << #x << ", tiedostossa " << __FILE__ << " rivillä "\
        << __LINE__ << "." << endl; }
#endif

#endif

Näin sitä sitten käytettäisiin:

#define DEBUG

#include "assert.hpp"


int main()
{
	int ika = -5;
	assert(ika >= 0);
	
	return EXIT_SUCCESS;
}

Kannattaa muuten varoa, ettei kirjoita assertia näin:

assert(status = OK); 

Sehän ei vertaile statuksen arvoa, vaan sijoittaa siihen OK:n. Siis mahdollinen virhetilanne häviää, mutta kun kun julkaisuversiossa assertit poistetaan, niin virhe tulee takaisin. O' mama, en haluaisi joutua tuollaista virhettä korjaamaan... Assertia ei pitäisi käyttää kuin ohjelmointivirheiden etsimiseen. Esimerkiksi tiedoston avaamisen onnistumista ei saa varmistaa assertilla, vaan sille pitää rakentaa järjestelmä, joka ilmoittaa käyttäjälle tilanteesta. Tiedoston puuttiminenhan ei ole virhe ohjelmassa, vaan lähinnäkin käyttäjässä. Mutta toisaalta ohjelman kehityksen alkuvaiheessa, kun virheiden käsittelyyn tarvittavia osia ei vielä ole ohjelmoitu - mutta ohjelmoija kuitenkin haluaisi varmistaa esimerkiksi tiedostojen avaamisen ja muistin varaamisen onnistumisen - voi vastaavaa makroa käyttää. Itse määrittelen makron tassert (temporary assert), jolla ohjelman alkuvaiheessa varmistan tällaiset tilanteet. Siinä vaiheessa kun tulee aika rakentaa virheidenkäsittelyn osat, on mahdolliset vaarapaikat helppo etsiä - siis kaikki tassert-makrot pitää korvata.

Takaisin