Több modulos programok

Czirkos Zoltán, Kohári Zsolt · 2023.10.19.

Röviden a modulokra bontásról azoknak, akik nem akarják megvárni az erről szóló előadást.

Miután végigolvastad ezt az írást, érdemes megismerkedni a CodeBlocks fejlesztőkörnyezetben a több modulos projekt létrehozásának a gyakorlatával.

1. Nagyobb projektek: több modul!

Ahogyan a kódot függvényekre bontjuk, ugyanígy hasznos az egész forráskódot több forrásfájlra osztani.

Ha a projekt egyetlen, nagy forrásállományban lenne megírva:

  • akkor áttekinthetetlen lenne,
  • a szerkesztő programok nehezen/lassan kezelnék,

Igazán nagy projekteknél pedig:

  • nehézkes lenne többen egyszerre dolgozni rajta,
  • egy-egy újrafordítás akár órákat vehetne igénybe.

Ez az egyedül fejlesztett programoknál is nagyon hasznos: az egyes modulok újra felhasználhatóak más projektekben. Ezért is érdemes a funkcionális egységeket általános módon megírni, hogy minél könnyebben fel lehessen használni őket más feladatok megoldásában.

2. Függőségek és fordítás

A futtatható program előállítása valójában mindig két lépésből áll:

  1. a forráskód lefordítása (compile) tárgykóddá (object code),
  2. a tárgykódok linkelése (link) futtatható programmá (executable).

A tárgykód olyan gépi kódú program, amelyben hivatkozások vannak (változónév, függvénynév) másik modulban található elemekre. Ezeket kell feloldani linkeléskor, hogy teljes, működő programot kapjunk. A linkelés során nem csak az általunk írt függvényeinket, hanem a szabványos C könyvtári függvényeket is megkeresi a linker, pl. printf(), fopen() és a többiek.

A három műveletet az itt látható parancsok begépelésével lehet elvégezni Linux rendszeren. Windowson is hasonlóképpen működik. A fordítási és szerkesztési lépéseket egyébként az integrált fejlesztőkörnyezetek automatikusan elvégzik, azt ritkán kell parancssorból, kézzel végeznünk: a laboron használt Code::Blocksban is csak egy kattintás, és indul is a lefordított programunk.

gcc  -c fizika.c  -o fizika.o
gcc  -c megjelenites.c  -o megjelenites.o
gcc  -c main.c  -o main.o
gcc  fizika.o megjelenites.o main.o  -o prog.exe

Ugyancsak automatikusan végzik a fejlesztőkörnyezetek a függőségek feltérképezését. Figyeljük meg az alábbi ábrán: az egyes fájlok módosítása esetén nem kell mindegyik műveletet újra elvégezni. Például ha a main.c fájl tartalmát szerkesztjük, akkor nincsen szükség a fizika.c újbóli fordítására, hiszen azon lépés által keletkező fizika.o fájl tartalma nem függ a main.c tartalmától. Szükség van viszont a main.c fordítása után az újbóli linkelésre is, hiszen a megújult main.o-tól függ a végleges programfájl, a prog.exe tartalma. Így végeredményben egy fordítást tudunk megspórolni ebben az esetben.

Nagyobb projekteknek ennél sokkal bonyolultabb függőségi gráfjuk van. A függőségek figyelembe vételével könnyen meghatározható az, hogy egy adott fájl módosítása esetén mely fordítási, linkelési lépéseket lehet elhagyni. Minél kisebb részekre, fájlokra van bontva a projekt, annál kisebbek lehetnek az újból elvégzendő lépések, hiszen annál több fájl marad változatlan egy kisebb módosítás esetén.

3. Főprogram és modulok

Melyik függvény,
hova való?
#include <stdio.h>
 
typedef struct Test {double x, vx, ax, m;} Test;
 
double hajitas(Test*, double); // fizika
void egy_dt_lepes(Test*, double*, double);
double erotorveny(Test*);

void adatok_kiir(Test const *, double); // megjelenites
void test_beolvas(Test*);
 
int main(void){
    Test kavics; 
    test_beolvas(&kavics);
    printf("Az eltelt idő %g mp.", hajitas(&kavics, 10));
}
 
double hajitas(Test* test, double cel){
    double t=0, dt=0.1; 
    adatok_kiir(test, t);
    while ( test->x < cel ) {
        egy_dt_lepes(test, &t, dt);
        adatok_kiir(test, t);
    }
    return t;
}
 
void egy_dt_lepes(Test* test, double*t, double dt){
    double f = erotorveny(test); test->ax = f / test->m; *t += dt; 
    test->vx += test->ax * dt; test->x += test->vx * dt;
}
 
double erotorveny(Test* test){
    return 42;
}
 
void test_beolvas(Test* test){
    scanf("%lf%lf%lf%lf", &test->x, &test->vx, &test->ax, &test->m);
}
 
void adatok_kiir(Test const *test, double t){
    printf("%g időpontban x=%g vx=%g ax=%g\n", t, test->x, test->vx, test->ax);
}

A programot logikusan több modulra lehet bontani. Az első a fő programmodul. Ez tartalmazza a main() függvényt, amely a programot vezérli. A másik modul a fizikához tartozó programrészekből áll össze: ez definiálja a test típust, és a szimulációt, valamint a fizikai törvényeket leíró függvényeket. Harmadik modul az, amelyik a megjelenítésért, a felhasználóval való kommunikációért felel: a röppálya pontjainak kiírásáért és a test adatainak beolvasásáért. Ha fájlba kiírás, vagy abból beolvasás van, az is ide tartozik.

Az egyes modulok nagyjából önállóak. A megjelenítésért felelő modul lecserélhető lenne egy olyan változatra, amely nem konzollal dolgozik (printf, scanf), hanem grafikus megjelenítést használ, és pl. a test pozícióját, sebességét egérkattintásból nyeri. A helyes modulokra bontás ismérve, hogy az egyes almodulok nem, vagy csak keveset kommunikálnak egymással; a fő programmodul dolga az, hogy az almodulok által adott programrészekből, azok függvényeinek hívásából egy működő, egész programot állítson össze.

Ha például a fizika modul egy listában, vagy egy nyújtózkodó dinamikus tömbben tárolná el az eredményeket, akkor a hajítás függvény ezt visszaadhatná a hívónak (main), amivel azután main() meg tudna hívni egy kiíró vagy kirajzoló függvényt. Ezzel megszűnne a két almodul közötti kommunikáció.

4. A modulok forrásfájljai: *.c

A program egyes részfeladatait, függvényeit az őket tartalmazó modul szerint különválasztjuk az egyes forrásfájlokba. Így jön létre a fizika.c, a megjelenites.c, amelyek lentebb láthatóak. Ezen felül lesz egy main.c fájlunk is, a főprogrammal.

fizika.c:
a fizika és a szimuláció dolgai
/* MÁS MODULBÓL NEM LÁTSZÓ FÜGGVÉNYEK: static */
static void egy_dt_lepes(Test* test, double*t, double dt){
    ………
}

static double erotorveny(Test* test){
    ………
}

/* MÁS MODULBÓL IS LÁTSZÓ FÜGGVÉNYEK */
double hajitas(Test* test, double cel){
    ………
}
megjelenites.c:
kommunikáció
void adatok_kiir(Test const *test, double t){
    ………
}

void test_beolvas(Test* test) {
    ………
}

A szimulációt és a fizikát leíró modul függvényei még tovább csoportosíthatóak. Vannak olyan függvények, amelyek más modulból is elérhetőek. Most az egyetlen ilyen a hajitas() függvény, mert azt a főprogramból is használjuk. Viszont a hajitas() függvényből hívott segédfüggvények (amelyek a szimuláció részleteit és a fizikai törvényeket valósítják meg), mint az egy_dt_lepes() és az erotorveny(), már nem érdekesek a főprogram számára. Olyan alacsony szintű műveleteket végeznek, amelyekkel a főprogramból már nem kell foglalkozni.

A függvények elé írt static kulcsszó azt mondja, hogy az a függvény csak abból a modulból (abból a forrásfájlból) kell elérhető legyen, máshonnan nem. Vagyis pl. a főprogramból nem lehet majd meghívni az erotorveny() függvényt – de nincsen is rá szükség. Elég, ha ennek a függvénynek a láthatóságát (scope) a szimulációt kezelő modulra korlátozzuk. (Egy változó vagy függvény (név) láthatósági tartományának (scope) azt a kódrészletet nevezzük, amelyben az adott név definiálva van.) A modul legalább egy függvénye viszont elérhető kell legyen a fő modulból, hiszen a main() függvény meg kell hívja.

5. Deklarációk és definíciók

A main.c fordításakor a fordítónak rengeteg deklarációra van szüksége:

main.c
#include "fizika.h"
#include "megjelenites.h"

int main(void) {
    Test kavics; 
    test_beolvas(&kavics);
    ……… hajitas(&kavics, 10);
}

A fejlécfájl: típusdefiníciók és függvénydeklarációk vannak benne.

fizika.h
typedef struct Test {
    double x, vx, ax, m;} Test;
 
double hajitas(Test*, double);
megjelenites.h
#include "fizika.h"

void test_beolvas(Test*);
void adatok_kiir(Test const *, double);

Vagyis fogjuk az összes olyan típust és függvényt, amelyeket láthatóvá szeretnénk tenni a többi modul számára, és készítünk belőlük egy fizika.h nevű fejlécfájlt a fizika.c modul mellé, és egy megjelenites.h nevű fejlécfájlt a megjelenites.c modul mellé.

Mi kerül a forrásfájlba (*.c), és mi kerül a fejlécfájlba (*.h)? Ez egyszerű: a kódfájlokba (*.c) mennek a függvények definíciói, a fejlécfájlokba mennek a függvények deklarációi. Persze csak azok, amelyeknek máshonnan is látszaniuk kell, másik modulból. A statikus függvényeket, amelyek csak az adott modulból látszanak, nincsen értelme, és nem is szabad szerepeltetni a fejlécfájlban. Ugyancsak a fejlécfájlokba mennek a kívülről is használható típusok definíciói.

Ezt a fejlécfájlt a többi modul, amely szeretné használni a fizika modul szolgáltatásait, beilleszti a saját forráskódjába az #include "fizika.h" sorral. Így a fordító érteni fogja, mi az, hogy Test, és azt is fogja tudni, hogy létezik a hajitas() függvény, ismeri a paramétereinek típusait és így tovább. Le tudja fordítani a kódot!

Figyeljük meg, hogy a projekt könyvtárában elhelyezett saját fejlécfájl nevét "idézőjelek" közé zárjuk az include-ban.

A megjelenites.h fejlécfájlban a függvények deklarációjában szerepel a Test típus, ezért itt is szükséges az #include "fizika.h" sor.

Ránézve a fenti main.c include-jaira, látható, hogy a fordítás során a fizika.h definiálja a Test típust, majd a megjelenites.h − a fizika.h betöltésén keresztül − újradefiniálja a Test típust. Ezt a compiler nem tolerálja, még akkor sem, ha pontosan ugyanaz a két definíció. A hiba elkerülésére a következő pontban találunk megoldást.

Fontos, hogy a fejlécfájlt nem csak a többi modulnak kell beillesztenie, hanem annak a modulnak is, amelyhez tartozik. Vagyis jelen esetben a fizika.c-nek is include-olnia kell a fizika.h-t! Ennek oka kettős: egyrészt a fizikát kezelő modul kódjának is ismernie kell a hozzá tartozó típust (Test), másrészt pedig így biztosítható az, hogy a modul forráskódjában nincsenek véletlenül hibásan megadva a függvények paraméterei. Ha a forrásfájlban más fejléccel definiálunk egy függvényt, mint a fejlécfájlban, akkor az a projekt fordításakor hibához vezet. Ha beillesztjük minden modulba a saját fejlécfájlját is, akkor ezeket a hibákat a fordító megtalálja.

6. Fejlécfájlok használata; #include guard-ok

A fizika modul fejlécfájlja:

fizika.h
#ifndef FIZIKA_H
#define FIZIKA_H

typedef struct Test {double x, vx, ax, m;} Test;
 
double hajitas(Test*, double);

#endif

Az #ifdef és #ifndef direktívákkal ellenőrizni tudjuk, hogy definiálva van-e egy makró, és attól függően egy programrészt teljesen kihagyhatunk a fordításból. Jelen esetben ezzel biztosítsuk, hogy a fejlécfájl tartalma ne illesztődjön be többször. Összetett projektek esetén ugyanis a fejléc fájlok általában egymást is betöltik (include). A fenti preprocesszor direktíva úgy működik, hogy az első betöltéskor még beilleszti a kódot, mivel a FIZIKA_H makró ilyenkor még nincs definiálva: #ifndef, if-not-defined. De egyből definiálja is, vagyis másodjára már az egész kódrészlet kimarad.

Ugyanígy kell eljárnunk valamennyi fejlécfájl esetén, érdemes a fájl nevéből képezni a makró azonosítókat, így biztosan nem lesz névütközés. Így már a compiler le tudja fordítani a főprogram modult, mert ezzel a technikával kiküszöböltük a Test típus újradefiniálását.

7. Globális változók használata

A függvényeken kívül definiált változók: globális változók. Ezeket minden függvény eléri.

  • Ezeket is megoszthatjuk modulok között.
  • A változódefiníciók a modulokba kerülnek.
  • A deklaráció az extern kulcsszóval történik. (Enélkül definíció!)
  • Statikus változó globálisan: csak az adott modulban látszik.

int globalis = 5;
modul.c
extern int globalis;
modul.h
#include "modul.h"

int main(void) {
    int a, b = 7;
    a = b + globalis;
foprogram.c

Itt jól látszik, mit jelent változók esetén a deklaráció és a definíció: deklaráció, amikor megmondjuk a típusát, definíció, amikor memóriát is foglalunk hozzá. Az extern-nel kezdődő sor csak deklaráció. Azt mondja a fordítónak, hogy van valahol egy ilyen nevű és ilyen típusú változó, akár egy másik modulban; az linkeléskor majd elő fog kerülni. (Az extern kulcsszó is egy tárolási osztályt (storage class) ad meg (storage class specifier); a linkernek fontos, hogy tudja, másik modulban kell keresni a változót.)

A globális változókat igyekszünk kerülni, ugyanis nehezen áttekinthetővé teszik a programot. Mivel mindegyik modulnak van hozzáférése a globális változókhoz, nem lehet tudni, melyik működése függ attól, és hogy melyik fogja azt módosítani. Ha a függvényeknek mindent paraméterben adunk át, akkor tiszta: csak az lehet a bemenő adat, ami paraméter, és csak az a kijövő adat, ami a visszatérési érték.

Láthatóság és élettartam: összefoglalás

Egy ilyen forrásfájlhoz:

modul.c
int globalis_valtozo;            // globális, projektben

int globalis_fv(void) {
   int lokalis;
   static int statikus_lokalis;
   ………
}

static int statikus_globalis;    // globális, de csak a modulban

static int modul_fv(void) {
   ………
}

Ilyen fejlécfájlt kell írni:

modul.h
#ifndef MODUL_H_INCLUDED
#define MODUL_H_INCLUDED

extern int globalis_valtozo;     // globálisok deklarációi
int globalis_fv(void);

#endif

A láthatóságot a fenti kommentek jelzik. A változók élettartamát (storage duration) is könnyű megjegyezni: a globális változók a program futásának egész ideje alatt léteznek, a lokális változók pedig csak akkor, amikor az őket létrehozó függvényben van épp a végrehajtás. A függvények statikus változói öszvérként viselkednek: a láthatóságuk lokális, azaz a függvényre korlátozódik, az élettartamuk viszont a globális változókéhoz hasonló. Hiszen éppen úgy tudják megőrizni az értéküket a függvényhívások között, hogy nem szűnnek meg a függvényből visszatéréskor.