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.
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.
A futtatható program előállítása valójában mindig két lépésből áll:
- a forráskód lefordítása (compile) tárgykóddá (object code),
- 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.
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.
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ó.
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.
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){
………
}
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.
A main.c
fordításakor a fordítónak rengeteg deklarációra van szüksége:
#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.
typedef struct Test {
double x, vx, ax, m;} Test;
double hajitas(Test*, double);
#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.
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.
A fizika modul fejlécfájlja:
#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.
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.cint 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.