Pár szó a C preprocesszorról

Múlt órán futólag szó esett a preprocesszorról, gondoltam, nem ártana, ha írnék róla valami kis bevezetőt.


WTF is a preprocessor anyway?

Eddig a C++ programok fordításáról csak elég homályosan beszéltünk. Tulajdonképpen csak annyit mondtunk, hogy létezik egy fordító, ami a forráskódból valami bonyolult módon gépi kódot csinál. A preprocesszálás, amit valahogy úgy is fordíthatnánk, mint előkészítés, ennek a fordítási folyamatnak a legelső lépése -- vagy ha úgy tetszik, a nulladik, hiszen ténylegesen nem része a fordításnak. A preprocesszor valójában egy teljesen különálló kis program, ami előkészíti a forráskódot a fordítónak, hogy a fordító könnyebben tudjon vele dolgozni: kiszedi a kommenteket, kiszedi az összes felesleges whitespace-t (szóközt, tabulálást, sorvégjelet), mindezt azért, hogy a fordítónak ne kelljen foglalkoznia ezekkel a karakterekkel, amik csak a kód olvashatóságát segítik elő, logikai jelentésük pedig nincs. A preprocesszor nem túl okos: ő maga nem ismeri a C++ szintaxisát, nem tudja értelmezni a kódot, stb, tulajdonképpen csak egy primitív kis szövegkezelő program. De tudunk neki utasításokat adni.

#include

Aki írt hello world programot, az már adott utasítást a preprocesszornak: az #include sor a program tetején egy utasítás a preprocesszornak. Az include szó tartalmazást, beágyazást jelent, és pusztán annyit csinál, hogy az argumentumként megadott file-t bemásolja az #include helyére - ennyi az egész, olyan, mint a copypaste. Ennek a gyakorlati haszna az, hogy ha egyszer megírunk egy rakás függvényt és kódrészletet, akkor azt többször is fel tudjuk használni, és ha változtatunk ezeken a külső függvényeken, akkor a változás minden programunkban megjelenik anélkül, hogy minden egyes programot külön változtatni kéne.

Az #include-nak két változata van: a már ismert #include <valami> és a #include "valami" megjelenésű. A különbség a kettő között annyi, hogy ha a beilleszteni (include-olni) kívánt file neve a kacsacsőrök között van, akkor először a rendszerkönyvtárakban kezdi keresni a file-t, míg ha idézőjelekben, akkor a jelenlegi könyvtárban keresi először, ha pedig ott nem találja, akkor a rendszerkönyvtárakban.

Általában az #include-dal olyan file-okat illesztünk be a szövegünkbe, amelyek függvénydeklarációkat tartalmaznak, de elvben akármilyen file-t #include-olhatunk. Emlékezzünk csak, a preprocesszor nem túl okos! Elvben abszolút lehetséges pici kódtöredékeket include-olni a programunk legközepén (de hát erre vannak a függvények); sőt, akármilyen, nem-kód szöveget is #include-álhatunk. Ennek a gyakorlati haszna kétséges, de minden szökőévben adódhat egyszer olyan helyzet, amikor pont ez kell nekünk.

Az #include-nak vannak bizonyos veszélyei. Például amikor #include-olunk egy file-t, akkor az abban lévő #include-ok is végrehajtódnak! A mi kis hello world programunkban #include-olt iostream file rengeteg más file-t include-ál, amiben rengeteg olyan dolog van, amit nem használunk, ezért a programocskánk teljesen fölöslegesen hatalmasra duzzad. A fordító általában elég okos ahhoz, hogy kioptimalizálja az ilyen problémákat, de szerintem ha például a hatalmas algorithm standard könyvtárból csak a max() függvényt használnánk, inkább ne #include-oljuk az algorithmet, és írjuk meg a max()-ot magunk. Röviden: csak óvatosan, ne #include-oljunk feleslegesen.

Makrók

A preprocesszor egy nagyon jó kis szövegkezelő program, abszolút képes rá, hogy bizonyos szövegrészeket másmilyen szövegrészekké alakítson. Ezt hívjuk makrónak. Például ha adott sugarú kör kerületét akarjuk kiszámítani:

	std::cout << 2*r*3.141593;
Ilyenkor elég macerás újra és újra leírni a pi közelítő értékét. Ilyenkor, vagyis gyakran használt konstansok esetén, érdemes lehet makróként definiálni a pi közelítő értékét a #define parancs használatával:
	#define PI 3.141593
És akkor már mondhatjuk, hogy:
	std::cout << 2*r*PI;
Egyrészt rövidebb leírni, nincs benne hibalehetőséget rejtő copypaste, másrészt sokkal szebb is a kifejezés, nyilvánvalóbb, hogy mit akarunk elérni a programmal. És ha később több tizedesjegy pontossággal akarjuk megadni a pi közelítő értékét, akkor nem kell mindenütt átírnunk a programunkat, hanem csak a #define utasítást kell megváltoztatnunk.

Tekintsünk egy talán szemléletesebb példát:

	if(yours > 18.65)
		{
		std::cout << "oh, how big! More than " << 18.65;
		}
Ehelyett mondhatjuk, hogy
	#define BIG 18.65
		
	if(yours > BIG)
		{
		std::cout << "oh, how big! More than " << BIG;
		}
De ne feledjük, hogy akármilyen szövegrészt makrósíthatunk, akár kifejezést is, sőt, makródefiníciókban hivatkozhatunk korábbi makródefiníciókra is:
	#define BIG 18.65 
	#define IS_BIG (yours > BIG)
	
	if(IS_BIG)
		{
		std::cout << "oh, how big! More than " << BIG;
		}
Mindez nem varázslat: semmi más nem történik, csak az, hogy a preprocesszor behelyettesít.

Emlékezzünk arra is, hogy a preprocesszor nem túl okos, és nem ismeri igazán a C++ szintaxisát. Ezért akár ilyeneket is írhatunk:

	#define while if
Ez a "while" szó összes előfordulását "if"-re cseréli. Ez céltalan és veszélyes játék, de jól demonstrálja a #define erejét. Csak óvatosan: a gondatlan vagy túl merész makróhasználat rettenetes szintaktikai és logikai hibákhoz vezethet.

A definiált makrókat szokás csupa nagybetűvel írni, és a program tetején, közvetlenül az include-ok alatt definiálni.

Hasznos makrók

Az ANSI C szabványnak hála bizonyos előre definiált makrók minden környezetben elérhetőek:

Az első két makró nagyon hasznos hibaüzenetek írásához:
	if(x<0)
		{
		std::cerr << "Hiba a " << __FILE__ << " file " << __LINE__ << " sorában, negatív szám!";
		}
A második kettővel például az about screenünket dobhatjuk fel:
	std::cout << "Version  " << MY_DEFINED_VERSION << ", built on " << __DATE__ << __TIME__;

Ezeken kívül a különféle fordítók különféle nonstandard makrókat is használnak. Ezek mindig két aláhúzással kezdődnek, ezért ilyen makróneveket ne adjunk.

Függvényszerű makrók

Lehetőségünk van arra, hogy a makróinkat flexibilisebbé tegyük, úgy, hogy, mint a függvények, képesek legyenek argumentumokat fogadni. Az argumentumok itt egyszerű kódrészletek, melyeket a preprocesszor egyszerűen, bután, szövegként bemásol a megfelelő helyre. Például:

	#define KORKER(r) 2*(r)*PI

		(...)

	std::cout << KORKER(x+25);
Ez pontosan olyan, mintha azt írtuk volna, hogy
			std::cout << 2*(x+25)*PI;
Egyszerű behelyettesítésről van szó.

Elágazások

A preprocesszor nem túl okos, de egyáltalán nem buta: vannak benne elágazások, mint egy rendes programnyelvben. Az elágazások szintaxisa egyszerű, bár nem igazán intuitív:

	#if VERSION > 2
		std::cout << "this is new stuff!\n";
		#define USE_ALL_NEW_STUFF 1
	#elif VERSION > 1
		std::cout << "this is moderately old stuff!\n";
		#define USE_ALL_NEW_STUFF 0
	#else
		std::cout << "this is very old stuff\n";
		#define USE_ALL_NEW_STUFF 0
	#endif
Vagyis: ha a VERSION nevű, korábban definiált makró nagyobb 2-nél, akkor kiír valamit, és definiálja a USE_ALL_NEW_STUFF makrót 1 értékkel. Ha nem nagyobb 2-nél, de nagyobb 1-nél, akkor kiír valami mást, és definiálja a USE_ALL_NEW_STUFF makrót 0 értékkel. Különben kiír valami mást, és a USE_ALL_NEW_STUFF-nak 0-t ad értékül.

Fontos, hogy csak nagyon limitált kifejezéseket írhatunk az #if után: érvényes C kifejezésnek kell lennie, és csak egészeket, karakterkonstansokat, matematikai alapműveleteket, bitszintű műveleteket és logikai operátorokat használhatunk benne. Ezek miatt a megkötések miatt érdemes lehet inkább az #ifdef ("ha definiálva van") és #ifndef ("Ha nincs definiálva") direktívákat használni:

	#ifdef DEBUG
		std::cout << "részeredmény: " << x << "\n";
	#endif
Ami annyit tesz, mint "Ha a DEBUG nevű makrót már definiáltam valahol korábban, akkor írjuk ki az x változó értékét, különben ne csináljunk semmit". Sok ilyen feltétel közbeiktatásával egyszerű a debugolás, és a debugolás végeztével a kész programból nem kell kitörölni a debug kiíratásokat, hanem egyszerűen nem kell definiálni a DEBUG makrót.

Nyilván, ugyanezt a hatást elérhetjük C++ kóddal is, ha például csinálunk egy const bool DEBUG=true; változót:

	if(debug) {
		std::cout << "részeredmény: " << x << "\n";
	}
Hatásában ugyanaz, ugyanúgy nem kell kitörölni a debug kódokat, ráadásul szebb is. Mégis van a makrós elágazásoknak egy hatalmas előnye: ha egy preprocesszoros elágazás feltétele nem igaz, az elágazásban lévő kódot nem fordítja le a fordító. Nagyon fontos, hogy ezt megértsük: ha a fenti példában a DEBUG makrót nem definiáljuk, akkor az elágazásban lévő kifejezés nem kerül bele a végső programba. Míg ha rendes C++ konstanssal dolgozunk, a debug kódok fölöslegesen bekerülnek a gépi kódba - ezzel fölöslegesen nő a programunk mérete, és a program futásakor a gép mindig fölöslegesen ellenőrizni fogja, hogy a debug értéke igaz-e vagy hamis.

A preprocesszoros elágazások akkor is hasznosak, ha több platformra tervezzük a programunkat. Például:

	#ifdef WINDOWS
		system("cls");
	#else
		system("clear");
	#endif
Vagyis "ha a WINDOWS nevű makrónk definiálva van és értéke nagyobb nullánál, akkor add ki a system("cls") parancsot, ami a windowsos parancssorban törli a képernyőt, de linuxos terminálokon szintaxis hibát ad; ha pedig nem, akkor add ki a system("clear") parancsot, ami a linuxos terminálokon törli a képernyőt, de windowson nem működik". Így ugyanaz a forrás ugyanúgy fog működni Windows és Linux alatt is, csak egyetlen makródefiníciót kell megváltoztatni, ami nem nehéz. GCC fordítóval a -D kapcsolóval tudunk definiálni "kívülről", a forrásfile változtatása nélkül makrókat, pl g++ -Wall -pedantic -D WINDOWS main.cpp -o windowsos.exe.

(NB egyáltalán nem szeretném, ha törölnétek a képernyőt, tudom, hogy csábító, de veszélyes út az, majd el is mondom, miért; a system() pedig általánosan gonosz. De pillanatnyilag nem jutott eszembe érzékletesebb és direktebb példa. Persze ez a példa messze nem tökéletes: csak akkor működik, ha a nem-windowsos terminálunkon a clear parancs valóban törli a képernyőt, de ezt semmi nem garantálja.)

Csak óvatosan

A preprocesszorutasítások nagyon erős és nagyon buta eszközök. Szintaxisuk elég életidegen és kifacsart logikájú ahhoz, hogy könnyű legyen apró szintaktikai hibákat véteni, melyek aztán meghökkentő és borzasztó szintaktikai, logikai és futásidejű hibákhoz vezethetnek. Általában, a preprocesszorutasítások túlzott használata egyértelműen veszélyes, míg legtöbbjük különösebb hátrány nélkül kiváltható rendes C++ kóddal. A C hőskorában egy okos függvényszerű makró használata valóban rengeteg erőforrást tudott spórolni; de ma már bízhatunk annyira a modern fordítók optimalizálási képességeiben, hogy ne igazán kelljen törődnünk a különbséggel, és ez a preprocesszor ellen billenti a mérleget. Különösen a függvényszerű makrókra igaz, hogy ha az ember nem preprocessor wizard, akkor használjon inkább egy kis rendes C++ függvényt. C-ben ma is a preprocesszor makrófüggvényei jelentik az egyetlen utat a típusfüggetlen, generikus függvények írásához, de a C++-ban erre vannak a template-ek.

Bizonyos programozási technikák, mint például az Include Guard Macro használatához elengedhetetlen a preprocesszor használata, és bizony egyáltalán nem árt, ha egy C++ programozó tisztában van a preprocesszorban rejlő lehetőségekkel; de legyünk tisztában a veszélyeivel is, és mielőtt bonyolultabb dolgokra használnánk, gondosan olvassunk utána, és fokozott óvatossággal írjunk le minden egyes sort.

Ajánlott irodalom: