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.

1. Nagyobb projektek

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:

  1. Programozóként is egyszerűbb a sok kicsi forrásfájlt kezelni, mint egy nagy, sok százezer soros fájlon dolgozni.
  2. Módosítás után hamarabb újrafordítható a program. Ha a modul1.c változott, csak azt kell újra lefordítani; mivel a modul2.c változatlan, a belőle keletkezett modul2.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.

2. Hivatkozások

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:

teglalap.c
struct Teglalap {
    double szeles, magas;
};

double terulet(Teglalap t) {
    return t.szeles * t.magas;
}
main.c
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:

main.c
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?

3. A fejlécfájlok

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.

teglalap.h
struct Teglalap {
    double szeles, magas;
};

double terulet(Teglalap t);
teglalap.c
#include "teglalap.h"

double terulet(Teglalap t) {
    return t.szeles * t.magas;
}
main.c
#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.

4. Modulok, előfordító

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!

teglalap.h
(bemenet)
struct Teglalap {
    double szeles, magas;
};

double terulet(Teglalap t);
teglalap.c
(bemenet)
#include "teglalap.h"

double terulet(Teglalap t) {
    return t.szeles * t.magas;
}
teglalap.c
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:

teglalap.h
(bemenet)
struct Teglalap {
    double szeles, magas;
};

double terulet(Teglalap t);
main.c
(bemenet)
#include <stdio.h>
#include "teglalap.h"

int main(void) {
    Teglalap t1;
    t1.szeles = 3;
    t1.magas = 5.2;
    printf("%f", terulet(t1));
}
main.c
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.

5. Az include guard-ok

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:

complex.h
struct Complex {
    double re, im;
};

double absolute(Complex c);
matrix.h
#include "complex.h" // 3

struct Matrix {
    Complex **adat;
    int szeles, magas;
};
main.c
#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:

main.c
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:

complex.h
#ifndef COMPLEX_H_INCLUDED
#define COMPLEX_H_INCLUDED

struct Complex {
    double re, im;
};

double absolute(Complex c);
/* ... */

#endif
matrix.h
#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:

main.c
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.