include
Czirkos Zoltán · 2021.08.24.
Forrásfájlok és fejlécfájlok használata. Részletes, kiegészítő magyarázatok az előadás anyagához, konkrét példákkal arra vonatkozóan, hogy mi történik a fordító belsejében.
Gyakran nem értett, rosszul használt eszköze a C nyelv modul rendszere – egészen pontosan, a nem létező modul rendszere. Mi szükség van a fejlécfájlokra? Minek a fejlécfájlokba, mit csinál az #ifndef-#define
páros? Mi az a #pragma once
? Ezeket a kérdéseket tisztázza ez az írás.
Egy program komplexitását, értékét nem lehet a programkód méretével mérni. Egy ötven soros program lehet egy bugyuta számkitalálós játék is, míg egy másik, szintén ötven soros program lehet egy titkosítást feltörni képes algoritmus.
A forráskód kezelhetőségét viszont már jobban tudjuk a sorok számával mérni. A Quake 3 motorja 300 000 soros, a Google Chrome nagyjából 6 millió kódsorból áll, a Facebook mögött kb. 60 millió sornyi programkód van (ezekről itt egy jó ábra: Codebases: Million lines of code.)
Ekkora programokat nem tehetünk egy fájlba, mert az fájni fog nekünk is és a gépnek is. Részrendszerekre, kisebb modulokra bontjuk a kódot, sok kis fájlt létrehozva. Így a kisebb forrásfájlokat mind lefordítva kapjuk meg a teljes, futtatható programunkat.
A fordítások által a forráskódból ún. object file (tárgykód fájl) keletkezik, amely már gépi kódú utasításokat tartalmaz. Ezekből pedig a linking nevű művelet állítja elő a teljes programot. Vegyük észre, hogy ez két szempontból is nagyon előnyös:
- Programozóként is egyszerűbb a sok kicsi forrásfájlt kezelni, mint egy nagy, sok százezer soros fájlon dolgozni.
- Módosítás után hamarabb újrafordítható a program. Ha a
modul1.c
változott, csak azt kell újra lefordítani; mivel amodul2.c
változatlan, a belőle keletkezettmodul2.o
is nyilvánvalóan az kell legyen. Ha egy 1 000 000 soros programunk van, 1000 darab 1000 soros fájlra bontva, csak 1000 sornyi kódot kell újra lefordítani.
A történet azonban sokkal bonyolultabb, mert a forrásfájlokban lévő programkódok összefüggenek. Az egyik forrásfájlban megírt függvényt a másikban szeretnénk meghívni. Legyen most egy nagyon egyszerű példánk! Adjunk meg egy típust, amely egy téglalap szélességét és magasságát tárolja. Továbbá legyen egy területszámító függvényünk, és használjuk ezeket fel a főprogramban! Első körben (egyelőre még hibásan) valahogy így:
struct Teglalap {
double szeles, magas;
};
double terulet(Teglalap t) {
return t.szeles * t.magas;
}
int main(void) {
Teglalap t1;
t1.szeles = 3;
t1.magas = 5.2;
printf("%f", terulet(t1));
}
Játsszunk el a gondolattal, hogy fordítóként kezelnünk kell a jobb oldalt látható main.c
fájlt. Ne feledjük, azért van több forrásfájlra bontva a program, hogy azokat külön lehessen kezelni, tehát a main.c
-t látva, és csak azt látva, kellene előállítani a gépi kódot tartalmazó main.o
fájlt. Tehát ennyi információnk van:
int main(void) {
Teglalap t1;
t1.szeles = 3;
t1.magas = 5.2;
printf("%f", terulet(t1));
}
Le lehet fordítani így a programot? Nem igazán. Első dolgunk az lenne, hogy a Teglalap
típusú t1
változónak helyet foglalunk a memóriában. Oké, de mennyit? Azt se tudjuk, mi ez a típus, vagy hogy egyáltalán létezik-e. Már emiatt fordítási hiba keletkezne egyébként.
Mi a helyzet az értékadásokkal? Biztos, hogy az ismeretlen t1
-nek van .szeles
adattagja? És ha van, annak mi a típusa? Ha pointer, akkor ez fordítási hiba. Ha egész szám, akkor oda a 3-at be kell másolni. Ha valós (amúgy most az, csak épp nem lehet tudni!), akkor a 3-as egész számot 3.0 valósként kell értelmezni. No meg aztán, mi az a terulet()
függvény? Mi a paramétere? Ha az, ami a t1
típusa, akkor oké – amúgy nem. És vajon mi a visszatérési érték? Honnan tudjuk, hogy mekkora helyet csináljunk a veremben a visszatérési értéknek, ha nem ismerjük a függvény fejlécét?
Látszik, hogy ez így nem működhet. A teglalap.c
-be belenézni nem szeretnénk. Hiszen épp az a cél, hogy a teglalap.c
forrásfájl feldolgozása nélkül tudjuk elvégezni a fordítást! Az 1000 darab 1000 soros forrásfájl példájára visszaemlékezve, ha minden kódot egyszerre akarnánk látni, ugyanott tartanánk, mint az előbb: egy-két kódsor megváltoztatása miatt újra fel kell dolgozni mind az 1 000 000 sort.
Ahhoz, hogy lefordíthatóvá váljon a kód, ismernünk kellene a Teglalap
típust és a terulet()
függvényt. Tehát nem a teljes teglalap.c
fájlt, hanem csak egyfajta kivonatát. Fontos a típus és a függvény fejléce – pillanatnyilag nem érdekes például a függvény belseje, implementációja.
Vegyük észre, hogy ezek olyan dolgok, amik fejlesztés közben ritkábban változnak. A téglalap típus kitalálásakor rögzítve lett már, hogy milyen tulajdonságai vannak. Lehetett tudni azt is, hogy a programban szükség lesz egy téglalap területét kiszámító függvényre. Ha több programozó dolgozott a projekten, akkor előzetesen rögzítették azt, hogy ezt a függvényt hogyan fogják hívni, és milyen paraméterezése lesz.
A main.c
forráskód lefordításához tehát ezek az információk kellenek:
struct Teglalap { // a típus
double szeles, magas;
};
double terulet(Teglalap t); // a függvény fejléce
Megtehetnénk azt, hogy ezt a kódrészletet bemásoljuk a main.c
tetejére, de nem teszünk ilyet, semmilyen körülmények között, mert a copypastelés ártalmas. Ha valamiből kettő másolat van, akkor az a két másolat különbözhet is... Az ezer fájlból álló projekt példájára visszatérve, ha csak 100-ban használjuk a téglalap típust, 100 helyre kell majd bemásolnunk a fenti kódrészletet. És 100 helyen átírni, ha mégis módosításra szorul, például ha kiderül, hogy kell egy kerulet()
függvény is, vagy tárolnunk kell a téglalap színét.
Tegyük be ezt a kivonatot egy külön, erre a célra létrehozott fájlba! Így születnek meg a fejlécfájlok. És természetesen legyen egy nyelvi eszköz, amellyel a fordítónak azt mondhatjuk, hogy „erre a helyre képzeld oda a fejlécfájl tartalmát”, ez pedig az #include
.
struct Teglalap {
double szeles, magas;
};
double terulet(Teglalap t);
#include "teglalap.h"
double terulet(Teglalap t) {
return t.szeles * t.magas;
}
#include "teglalap.h"
int main(void) {
Teglalap t1;
t1.szeles = 3;
t1.magas = 5.2;
printf("%f", terulet(t1));
}
Hasonlítsuk ezt össze az eredeti kóddal! A Teglalap
típus definíciója eltűnt a teglalap.c
-ből; átmozgattuk a teglalap.h
fájlba. A terulet()
függvénynek pedig a fejlécfájlban csak a deklarációja (prototípusa) szerepel, a törzse nem.
A fenti séma alapján minden forrásfájlhoz – amelynek függvényeit, típusait más forrásfájlból használni szeretnénk – létrehozunk egy fejlécfájlt. A kettő egymáshoz is tartozik, és együtt a programunk téglalap moduljához tartoznak. Együtt kell őket karbantartanunk is, mert ha a típus vagy a függvényei változnak, módosítanunk kell a forrásfájlt és a hozzá tartozó fejlécfájlt is. De legalább csak ezeket, mert a módosuló fejlécfájlt minden további forrásfájl #include
-olja, nincs ténylegesen odamásolva a kód.
A fordítási műveletet technikailag két jól elkülönülő részre bontják. A forrásfájlt előbb egy előfordító (preprocessor) kapja meg, amely az #include
sorokat kezeli, és előállít egy olyan ideiglenes fájlt, amely a tényleges fordító bemenete lesz. Ez az ideiglenes fájl már nem tartalmaz #include
-okat, hanem az #include
sorok ebben ki vannak cserélve a beszúrt fájlok tényleges tartalmával!
(bemenet)
struct Teglalap {
double szeles, magas;
};
double terulet(Teglalap t);
(bemenet)
#include "teglalap.h"
double terulet(Teglalap t) {
return t.szeles * t.magas;
}
előfordítás után
(köztes állapot)
# 1 "teglalap.c"
# 1 "teglalap.h" 1
struct Teglalap {
double szeles, magas;
};
double terulet(Teglalap t);
# 2 "teglalap.c" 2
double terulet(Teglalap t) {
return t.szeles * t.magas;
}
Érdemes megfigyelni, hogy az előfordításból keletkezett köztes, ideiglenes fájlban a terulet()
függvény deklarációja és definíciója is szerepel. Ez előnyös, mert ha elrontottuk volna, és nem egyeznek a függvényfejlécek a teglalap.c
-ben és a teglalap.h
-ban, itt tud szólni a fordító.
Látszik, hogy az előfordító egyébként jelzéseket is tesz az ideiglenes fájlba. Ezt mi nem látjuk, és nem is írhatunk ilyen kódot. A fordítás következő fázisában azonban a fordító látja, és ebből tudja, hogy melyik eredeti fájlok összevagdosása által keletkezett az a fájl, amivel a dolgoznia kell. Erre azért van szükség, mert a hibaüzenetekben sorszám szerint hivatkozza a hibás sorokat a programozó számára. Ezekből a jelzésekből tudja visszafejteni a fordító, hogy egy esetleges hibás sor eredendően melyik fájlból származott.
A main.c
fájl előfordítása során a teglalap.h
is bemásolódik az ideiglenes fájlba, továbbá az stdio.h
is. Ne feledjük, az kell a printf()
miatt; az a teglalap.h
-hoz hasonló fejlécfájl. A teljes, előfordított main.c
-t az alábbi ábra nem mutatja meg, ugyanis (nálam) az stdio.h
fájl 946 soros. Csak a lényeges részei:
(bemenet)
struct Teglalap {
double szeles, magas;
};
double terulet(Teglalap t);
(bemenet)
#include <stdio.h>
#include "teglalap.h"
int main(void) {
Teglalap t1;
t1.szeles = 3;
t1.magas = 5.2;
printf("%f", terulet(t1));
}
előfordítás után
(köztes állapot)
# 28 "stdio.h" 2 3 4
/* ... */
# 1 "teglalap.h"
struct Teglalap {
double szeles, magas;
};
double terulet(Teglalap t);
# 3 "main.c" 2
int main(void) {
Teglalap t1;
t1.szeles = 3;
t1.magas = 5.2;
printf("%f", terulet(t1));
}
Látjuk, hogy elértük a célunkat: az előfordított fájlban szerepel a Teglalap
típus definíciója és a terulet()
függvény deklarációja. A fordító így már tud dolgozni.
Tekintsünk most egy bonyolultabb példát. Képzeljünk el egy programot, amely komplex számokat tartalmazó mátrixokkal dolgozik!
Ebben legalább három, jól elkülöníthető modul lesz. Az egyik a komplex szám modul: ez tartalmazza a komplex típust, továbbá az aritmetikai műveletek leírását: összeadást, szorzást és a többieket. A másik modul a mátrix programkódja; ez nagyrészt dinamikus tömbökről és hasonlókról szól. Végül pedig a harmadik a főprogramunk, az alkalmazás, amihez a komplex számot és a mátrixot írtuk.
A komplex számos modul önálló. A mátrix viszont használja a komplex számot, sőt a mátrix típus definíciójához már szükségünk van rá. A főprogram pedig használja a komplex számot és a mátrixot is, ezért a főprogramot író programozó #include "complex.h"
-t és #include "matrix.h"
-t is ír majd a kódjába. A három fájl:
struct Complex {
double re, im;
};
double absolute(Complex c);
#include "complex.h" // 3
struct Matrix {
Complex **adat;
int szeles, magas;
};
#include "complex.h" // 1
#include "matrix.h" // 2
int main(void) {
Complex c = { 2.3, 4.5 };
Matrix m;
/* ... */
}
Vajon milyen ideiglenes fájl pottyan ki az előfordítóból, amikor a main.c
forrásfájlt fordítjuk? Először is, az (1)-es #include "complex.h"
sort kicseréli a complex.h
fájl tartalmával. Aztán a matrix.h
(2)-es hivatkozását cseréli ki a matrix.h
tartalmával. De az így beszúrt fájl is hivatkozza a complex.h
-t, tehát még rekurzívan a (3)-as sor is kicserélődik a complex.h
fájl tartalmára. Milyen fájlt kapunk így? Ezt:
előfordítás után
struct Complex {
double re, im;
};
double absolute(Complex c);
struct Complex { // másodjára!
double re, im;
};
double absolute(Complex c);
struct Matrix {
Complex **adat;
int szeles, magas;
};
int main(void) {
Complex c = { 2.3, 4.5 };
Matrix m;
/* ... */
}
Márpedig ez gond! Az absolute()
függvény kétszeri deklarációja belefér, de a Complex
típus kétszeri definíciója nem. Egy dolgot ugyanis csak egyszer lehet definiálni, ezt előírja a C nyelv. Ez az ODR-szabály (one definition rule). Azért lehet mindent csak egyszer definiálni, mert ha valamiből kettő van, akkor azok különbözhetnek is. Márpedig ha hol így, hol úgy definiálnánk a komplex szám típust, akkor biztosan helytelen kódot generálna a fordító. Ezért az előírás számára, hogy ha ugyanannak a dolognak – típusnak, függvénynek, bárminek – egynél több definícióját látja, akkor hibaüzenettel meg kell állnia.
Azért is gond a többszöri beillesztés, mert végtelen rekurzióhoz vezethet. Képzeljük el azt, hogy A
modul függvényei használják B
modul függvényeit, és B
modul függvényei is hívják A
modul függvényeit. Ezek szerint A
modul #include
-olná a b.h
-t, illetve B
modul is #include
-olná a.h
-t. Az előfordító pedig sosem állna le.
Ezért ki kellett valamit találni, hogy a fejlécfájlok többszöri beillesztését elkerüljük. Ezt a szabvány szerint az előfordító makró eszközeivel kell megoldani, ún. include guard-dal. Egy include guard így néz ki:
#ifndef VALAMI_H_INCLUDED
#define VALAMI_H_INCLUDED
/* ... */
#endif
Ennek az #ifndef
-#endif
közötti része csak akkor kerül bele az előfordító által előállított ideiglenes fájlba, ha a VALAMI_H_INCLUDED
előfordító makró nincs definiálva (if-not-defined). Ha bekerül, akkor viszont definiálódik a makró. Mi is történik, ha ezt a fájlt kétszer látja az előfordító egymás után?
#ifndef VALAMI_H_INCLUDED // itt még nincs definiálva a makró
#define VALAMI_H_INCLUDED // tehát innentől kezdve bekerül...
/* ... */
#endif // ... idáig
#ifndef VALAMI_H_INCLUDED // másodjára viszont definiálva van
#define VALAMI_H_INCLUDED // tehát innentől kezdve kimarad...
/* ... */
#endif // ... idáig
A fejlécfájlokat tehát így kell megszerkeszteni, a teljes törzsüket include guard-okba csomagolva:
#ifndef COMPLEX_H_INCLUDED
#define COMPLEX_H_INCLUDED
struct Complex {
double re, im;
};
double absolute(Complex c);
/* ... */
#endif
#ifndef MATRIX_H_INCLUDED
#define MATRIX_H_INCLUDED
#include "complex.h"
struct Matrix {
Complex **adat;
int szeles, magas;
};
#endif
Az előfordított main.c
fájl pedig valami ilyesmi, //
kommenttel jelképezve a makrók segítségével kihagyott részt:
előfordítás után
/* a complex.h-ból származó rész */
struct Complex {
double re, im;
};
double absolute(Complex c);
/* a matrix.h-ból származó rész */
// itt #include-olódott volna másodjára a complex.h, de kimaradt
// struct Complex {
// double re, im;
// };
// double absolute(Complex c);
struct Matrix {
Complex **adat;
int szeles, magas;
};
/* innentől pedig a main.c tartalma */
int main(void) {
Complex c = { 2.3, 4.5 };
Matrix m;
/* ... */
}
Az előzőhöz hasonló hatást érhetünk el, ha a makrómágia helyett #pragma once
-ot írunk a fejlécfájljaink elejére. Bár ez nem szabványos (ZH-ban pont nem jár rá...), a legtöbb fordító felismeri.