Újabb vezérlési szerkezetek

2. Hátultesztelő ciklus

A ciklusmagot egyszer biztosan le kell futtatni?

Előfordul, hogy egy ciklus törzsét legalább egyszer biztosan le akarunk futtatni, vagy hogy a ciklus feltételének nincs értelme, amíg legalább egyszer a törzse le nem futott. A hátultesztelő ciklusban a ciklusfeltétel ellenőrzése a ciklusmag után történik. Emiatt a ciklusmag legalább egyszer végrehajtódik, tehát az első végrehajtás a feltételtől függetlenül megtörténik. A forráskódban is a feltétel alul van. Ez emlékeztet arra, hogy csak a ciklusmag után ellenőrzi:

Hátultesztelő ciklus a folyamatábrán
Hátultesztelő ciklus struktogramon
do
   utasítás;
while (feltétel);
do {
   utasítások…
} while (feltétel);

Példa: 5 darab lottószám – amikor kitalálunk egy újat, megnézzük, volt-e már. Ha igen, újra megpróbáljuk. Persze ilyenkor kiadódhat megint ugyanaz a szám, de előbb-utóbb a véletlenszámgenerátor dob majd egy különbözőt:

int szamok[5];

for (i = 0; i < 5; i += 1) {
   do {
      uj_szam = rand()%90 + 1;
      van_ilyen = false;        // van már ilyen?
      for (j = 0; j < i; j += 1)
         if (szamok[j] == uj_szam)
            van_ilyen = true;
   } while (van_ilyen);         // ha van, újra!

   szamok[i] = uj_szam;
}

Természetesen a fenti programot egy pillanat alatt át lehetne írni elöltesztelő ciklusra. Ha elöl lenne a van_ilyen tesztelése, csak annyit kellene tenni, hogy azt a ciklusba belépés előtt IGAZ-ra állítjuk; mert akkor először biztosan bemegyünk a ciklus belsejébe:

van_ilyen = true;
while (van_ilyen) {
    uj_szam = rand()%90+1;

    van_ilyen = false;
    for (j = 0; j < i; j += 1)
        if (szamok[j] == uj_szam)
            van_ilyen = true;
}

Mi a különbség a kettő között? Az, hogy itt praktikusabb a hátultesztelő, mert egy számot biztosan kell sorsolnunk. Még egy különbség van: az elöltesztelőnél kvázi trükközni kell, hogy először bemenjünk a ciklusba, és emiatt tartalmaz egy furcsa kódsort. Az oda nem illő sor a ciklus előtti van_ilyen = true – ez valami olyasmit állít, ami nem igaz. Hogy állíthatjuk azt, hogy már van olyan szám, ha még nem is sorsoltunk?

A teljes program a lottószámok generálására letölthető innen: lotto.c.

Megjegyzés a fenti „előbb-utóbb a véletlenszámgenerátor dob majd egy különbözőt” gondolathoz. Valóban, elviekben lehetséges, hogy ez a program soha nem áll le, mert lehetséges az, hogy a véletlenszámgenerátor soha nem ad olyan számot, ami jó lenne. De ez nagyon valószínűtlen – valószínűségszámítással meg is lehetne határozni, mennyire. Itt most ezzel nem foglalkozunk. Létezik a lottószámok generálására egy teljesen más elvű megoldás is, amely garantálhatóan rövid ideig fut: feltöltünk egy tömböt a számokkal 1…90-ig, aztán megkeverjük a tömböt, és vesszük az így kapott tömb első 5 elemét.

3. Spagetti ciklus? break és continue

Ciklus megszakítása: break

break
 while (keresés…) {
     …
     if (megvan)
┌───   break;
│    …
│}
└─▸

Ciklus folytatása, ciklustörzs kihagyása: continue

continue
┌─▸ while (vannak feldolgozandó elemek…) {
│     if (a mostani elem nem kell)
└───     continue;

      …
    }

Ezek az utasítások nem strukturált vezérlési szerkezeteket eredményeznek. Csak nagyon indokolt esetben használjuk őket! A break és continue használatára egész félévben gyakorlatilag nem fogunk más példát mutatni. Legyen az egész félév összes programja példa inkább arra, hogy nagyon jól meg lehet lenni break és continue nélkül is. (És főleg goto nélkül!)

A break és a continue amúgy használhatóak for ciklussal is. Vigyázat, a continue utasítás következő iterációra ugrása a for ciklus esetében azt jelenti, hogy a ciklus fejlécében megadott műveletet, az utótevékenységet még végrehajtja!

4. Esetszétválasztás: switch()

1. Adatbevitel
2. Módosítás
3. Kimutatás
…
0. Kilépés

Választás: _
if (valasz == 1) {
    …
} else if (valasz == 2) {
    …
} else {
    …
}

A sorozatos if …; else if … kiváltására használható a switch. Az if igazságértékre alapozott választás helyett ezzel ún. többszörös választás vezérlési szerkezetet lehet megadni a programkódban.

Például amikor megkérdezzük a felhasználót, szeretne-e törölni egy fájlt, ötféle választ adhat: kicsi és nagy I betű, kicsi és nagy N betű, vagy valami más:

printf("Töröljem a fájlt? (I)gen vagy (n)em? ");
scanf(" %c", &valasz);

switch (valasz) {
    case 'I': /* kis- és nagybetű is jó */
    case 'i':
        printf("Igent válaszoltál, törlöm!\n");
        break; // kiugrás

    case 'N':
    case 'n':
        printf("Nemet válaszoltál, meghagyom.\n");
        break;

    default:
        printf("Érvénytelen válasz!\n");
        break;
}

A fenti scanf()-ben a %c előtti szóköz szándékos. Ez annyit tesz, hogy a karakter beolvasása előtt kapott összes szóköz, újsor és tabulátor (whitespace) karaktert eldobja. Ezekkel az apróságokkal az előadásban nem foglalkozunk, csak külön írásban.

A switch szerkezeten belül az egyes értékekhez tartozó, több utasításból álló kódot nem kell utasításblokkba tenni. Az egész egyetlen egy, nagy utasítás blokk, amelynek belépési és kilépési pontjait a case-ek és a break-ek adják meg. Fogalmazhatunk így is: a case kulcsszavakkal jelölt helyek a switch utasításon belüli utasításszekvenciába belépési pontok. Amelyiknek megfelel a kifejezés értéke, oda ugrik a végrehajtás. Ha nem teszünk break-et az utasítások után, akkor a végrehajtás továbbmegy a következő belépési pontnál található utasításokra, és végrehajtódnak azok is! (Ennek az angol neve: fall-through.) Ezt használjuk ki akkor, amikor több case-t írunk egymás után: fent a case 'I' után nincs utasítás, de break sincs.

Emiatt szokás az esetek 99%-ában így használni a switch szerkezetet. Minden csoportban előbb a case-ek segítségével felsorolt lehetőségek, utána az utasítások, végül a break:

switch (kifejezés) {
    case érték1:      // ha érték1 vagy érték2, kezdd itt
    case érték2:
        …
        break;        // ugorj ki itt

    case érték4:      // ha érték 4, akkor itt kezdd
        …
        …
        …
        break;        // és itt ugorj ki

    default:          // ha egyik sem (opcionális)
        …
        break;
}

A switch lehetőségei korlátozottabbak annál, mint ami egy if – else sorozattal kifejezhető. A legfontosabb megkötés az, hogy a case kulcsszavaknál megadott értékek csak egész típusúak lehetnek (ide értve természetesen a karaktereket is, mert azok is egész számok). Ráadásul konstansoknak, azaz fordítás közben már ismert értékűeknek kell lenniük. A switch fejlécében használt kifejezésnek is egész számra kell kiértékelődnie, ez a C nyelvben kötelező. A default ág elhagyható.

A switch szerkezettel egy egyszerűbb menürendszert is elkészíthetünk a programunkban. Erről egy laborfeladatban lesz szó.

Függvények

6. Prímszámok 2-től 1000-ig

Számoljuk meg, hány prímszám van 2 és 1000 között!

Számlálás tétele
int db = 0;
int sz;
for (sz = 2; sz <= 1000; sz += 1)
   if (… sz egy prím …)  !
      db += 1;
Eldöntés tétele
bool van = false;
int oszt = 2;
while (oszt < sz && !van) {
   if (sz % oszt == 0)
      van = true;
   oszt += 1;
}

Teljes megoldás
int sz, db;

db = 0;
for (sz = 2; sz <= 1000; sz += 1){
   bool van = false;
   int oszt = 2;
   while (oszt < sz && !van) {
      if (sz % oszt == 0)
         van = true;
      oszt += 1;
   }
   if (!van)
      db += 1;
}

printf("%d prím.\n", db);

A két egymástól független programrész összedolgozása által egy nehezen értelmezhető program keletkezett. Figyeljük meg: nagyban rontotta az áttekinthetőségét az, hogy az egyik algoritmust bele kellett építenünk a másikba, kettévágva azt. Megírni is nehezebb egy ilyet, hiszen egyszerre több dolgon kell gondolkozni.

Figyeljünk meg a fenti kódrészleten még egy dolgot. A vizsgált szám prím voltát ellenőrző, kék színű programrész tulajdonképpen önállóan is megállná a helyét: van bemenete (a vizsgált sz szám) és kimenete (prím-e vagy nem). Ez emlékeztethet minket a matematikai függvényekre: egy f(x) = x² függvény is értelmes önmagában, akár egy másik képlet részeként, és ennek is van bemenete (az x szám) és kimenete (annak négyzete, szorzata önmagával).

7. Alprogramok = szubrutinok = függvények

Függvények (function) a C nyelvben

Hasonlóak a matematikai függvényekhez:

y = f(x)
y = x²
double negyzet(double x) {
   return x * x;
}

int main(void) {
    printf("%f", negyzet(2.3));
}

Tegyük fel, hogy van egy programrész, amely megmondja egy adott számról, hogy prím-e, vagy nem. A fenti f(x) = x² mintájára képzeljünk el egy prim_e(x) függvényt! Ez a kapott számnak nem a négyzetét fogja visszaadni (pl. negyzet(1.5)2.25), hanem a kapott számról megadja majd, hogy prímszám-e vagy nem (pl. prim_e(37)true és prim_e(25)false). Ha van egy ilyen függvényünk, akkor a prímek számlálása feladat nagyon egyszerűvé válik: a kékkel jelölt „… sz egy prím …” programrész helyére csak annyit kell írnunk, hogy prim_e(sz).

if (… sz egy prím …)
   db += 1;
if (prim_e(sz))
   db += 1;

Az egész programunk így együttműködő alprogramokból épülhet fel! Ezekhez is tartozik specifikáció: bemenet → kimenet összefüggése.

Vegyük észre: azáltal, hogy a függvénynek neve van, a programban akár több helyen is hivatkozhatunk rá: több helyről indulva elvégezhetjük ugyanazt a részfeladatot. Egy bonyolult részfeladat így elemi lépésként is kezelhető. Ha megvan a prim_e() függvényünk, onnantól kezdve ugyanolyan könnyen tudjuk ellenőrizni egy szám prím/nem prím voltát egy if (prim_e(i)) sorral, mintha csak annyit kellene ellenőrizni, hogy nulla vagy nem nulla! Azáltal pedig, hogy a függvényeknek paraméterei is lehetnek, ezeket a részfeladatokat tudjuk „konfigurálni”. A printf() függvényt is egyszer megírta valaki, és mindenhol használhatjuk.

8. Függvény példa: prímek 2-től 1000-ig

A részfeladatokat külön függvényben írhatjuk meg. Így egy nagyobb program áttekinthetőbb, kezelhetőbb lehet. Sőt a gyakran ismétlődő programrészeket is így csak egyszer kell majd megírnunk. A programok így kisebbek, hatékonyabbak lehetnek.

Főprogram

int main(void) {
  int db = 0;
  for (int s=2; s<=1000; s+=1)
    if (prim_e(s))
      db += 1;

  printf("%d db.\n", db);

  return 0;
}

Alprogram

bool prim_e(int szam) {
  bool van = false;
  int o = 2;
  while (o < szam && !van) {
    if (szam % o == 0)
      van = true;
    o += 1;
  }
  return !van;
}

Elnevezések: fejléc, függvénytörzs, paraméter, visszatérési érték, hívás, visszatérés, lokális változó, láthatóság, élettartam.

Dokumentálandó: bemenet, kimenet, feladat, hibalehetőségek.

A prim_e() függvény olyan, mint a teljes programunk: van bemenete és kimenete is. Csak ezek nem a képernyő és a billentyűzet, hanem a főprogrammal történő kommunikáció által valósulnak meg:

Főprogram és alprogram kommunikációja

Függvények definiálása: szintaktika

visszatérési_típus függvénynév(paraméterlista) {
   … függvénytörzs …

   return visszatérési_érték;
}

A fejléc (header) meghatározza a függvény nevét, a paraméterei (parameter) és a visszatérési értéke (return value) típusát. A függvénytörzs (function body) tartalmazza azt a programrészt, amely a függvény feladatát elvégzi.

Visszatérés a függvényből

A függvény törzsében elhelyezett return utasítással visszatérhetünk (return) a függvény hívásának (call) helyére. Ezzel egyben megadjuk a visszatérési értéket is, amelyet egyébként a függvény értékének (function value) is nevezünk. Fontos, hogy a return utasítás ezt a két szerepet elválaszthatatlanul összeköti! Ami a return után van, az már nem hajtódik végre. (Viszont egy függvényben lehet több helyen is return utasítás.)

Lokális változók

  • A függvényen belül vannak definiálva
  • Függvénybe belépéskor jönnek létre
  • Végén megszűnnek → értékük elveszik
  • Minden függvény csak a sajátjait látja! (láthatóság, scope)

Mi az előnyük? Például az, hogy minden függvénynek lehetnek saját lokális változói, amelyekben olyan értékeket tárolnak, amelyekre csak a futásuk idején van szükség. A fenti példában az osztó változóra nincsen már szükség, amint meghatároztuk, hogy a szám prím-e. (A van változó is lokális, és az is megszűnik, azonban az értéke lemásolódik, és átadódik a hívónak.) Ezek a változók csak a függvényen belül látszanak (a láthatóságuk (scope) csak a függvényen belülre terjed ki), és így a nevük csak azon belül értelmezett. Másik függvényeknek lehetnek ugyanolyan nevű lokális változóik, a nevek mégsem fognak ütközni. További előny, hogy a változó nem foglal memóriát, csak akkor, ha az azt definiáló függvény belsejében vagyunk. Vagyis a változó élettartama (storage duration, lifetime és extent szavak is használatosak az angol szakirodalomban) is csak arra az időre terjed ki, amíg a függvény végrehajtása tart.

A main() függvény

Most már tudjuk, hogy a main() is egy függvény. Egy egész számmal kell visszatérjen, amelynek hibajelző szerepe van. Egyelőre mindig 0-ra állítjuk, ami azt jelenti, hogy nincs hiba. Hogy a paraméterei mik, azt a kérdést egyelőre hagyjuk nyitva!

Függvények dokumentációja

A függvények olyan kis programrészek, amelyek egy jól elhatárolt részfeladatot hajtanak végre. Ezért egy függvény dokumentálásakor pontosan meg kell határozni, hogy mire való, milyen feladatot hajt végre. A programokhoz hasonlóan rögzíteni kell azt is, hogy milyen bemenetet vár és milyen kimenetet állít elő a futása során. A bemenet dokumentálásához hozzá tartozik a bemeneti tartomány leírása is (pl. a négyzetgyököt vonó függvény nem kaphat negatív számot paraméterként). A működés leírásához pedig a hibalehetőségek rögzítése. Mindezeket a függvények előtt, a forráskódban, megjegyzés (comment) formájában is meg kell tenni, hogy ezáltal a kód kezelhetővé, karbantarthatóvá váljon. Ahol van hely, ezt meg fogjuk tenni (sajnos az előadás diákra nem mindenhol fér oda ez).

9. A függvényhívás menete

A lenti animáció a függvényhívás menetét és lokális változók élettartamát mutatja be.

#include <stdio.h>
    
int faktorialis(int mie) {
   int szorzat = 1;    
   for (int i = 2; i <= mie; i += 1)    
      szorzat *= i;    
   return szorzat;    
}
 
int main(void) {
   int sz;
   int eredm;

   printf("sz = "); scanf("%d", &sz);    
   eredm = faktorialis(sz);    
   printf("%d! = %d\n", sz, eredm);    
   return 0;    
}


main()

sz:    
eredm: 

A függvényhívás a következőképpen történik. Amikor main() függvény meghívja a faktorialis() függvényt, létrejönnek az utóbbi lokális változói. Ezek közül egyik a függvénynek a paramétere: a mie változó kívülről inicializálva lesz, méghozzá azzal az értékkel, amelyet a hívás helyén adunk neki (tehát amelyet a felhasználó adott meg). Így a három lokális változó közül az egyik, a paraméter mie inicializálva van, a másik kettő pedig – szorzat és i – nem. De azok később kapnak értéket a számítás elvégzése közben.

A függvényhívás a return szorzat utasítás hatására fejeződik be. Ekkor a függvény lokális változói mie, szorzat és i megszűnnek – de a return utasításnál megadott kifejezés értéke (ami most a szorzat-ba került szám) visszaadódik a hívónak. Ez az int típusú érték lesz a main() kódrészletben a faktorialis(sz) részkifejezés értéke. Innen folytatódik a main() végrehajtása.

A függvény paraméterét és visszatérési értékét a C nyelvben tetszőleges kifejezés értékével inicializálhatjuk. Tehát a fenti függvény hívható lenne akár így is: faktorialis(5), vagy így is: faktorialis(sz+6), amikor is az 5! és az (sz+6)! értékeket számolná ki. Továbbá egy függvény visszatérését kiváltó return utasítás után is tetszőlegesen bonyolult kifejezés állhat: egy függvény befejeződhet akár egy return x, akár egy return 5, de akár egy return sin(x)*3 + 1.2 utasítással is. (Lásd a fentebbi és a lentebbi példákat is!)

10. Függvények egy sorban

Néha egy függvény olyan rövid, hogy egy sorban leírható.

Ha csak egyszer írjuk és olvassuk a változót:

int teglalap(int a, int b) {
    int t = a * b;
    return t;
}
int teglalap(int a, int b) {
    return a * b;
}

Ebben a példában azt vehetjük észre, hogy egy egyszer írt, egyszer olvasott változónk van. Az első, hosszabb változatban a t változóba előbb beírjuk a * b értékét, aztán kiolvassuk onnan. A kiolvasott szám mi más lenne, mint a * b – ugyanezt akár a return utasításhoz is írhatjuk, elhagyva a szükségtelen lokális változót.


Ha logikai kifejezés értékével térünk vissza:

bool paros_e(int x) {
    if (x % 2 == 0)
        return true;
    else
        return false;
}
bool paros_e(int x) {
    return x % 2 == 0;
}


Itt azt vehetjük észre, hogy az első változatban a vizsgálat eredménye (nulla-e a maradék vagy más: igaz vagy hamis) éppen a visszatérési értéket adja. Amikor a maradék nulla, akkor az x % 2 == 0 kifejezés értéke igaz, és ilyenkor a return true-hoz visz a vezérlési szerkezet. Ha a maradék nem nulla, akkor az x % 2 == 0 értéke hamis, és a return false-hoz. Vagyis a kifejezés értéke pont ugyanaz mindkét esetben, mint ami a return utasítások után van, így az esetszétválasztás felesleges.

Egyik esetben sem hiba a hosszabb megoldást használni. Egyszerűen csak felesleges és hosszabb.

11. Függvények paraméterei

double teglalap_kerulet(double a, double b) {
   return 2 * (a+b);
}
printf("%f", teglalap_kerulet(2, 3.4)); // a=2, b=3.4

Formális paraméter (parameter): a neveik a függvény fejlécében.

  • Szimbolikus paraméternek is nevezik (symbolic parameter)
  • A függvényen belüli szerep szerint kell elnevezni
  • Automatikusan inicializált lokális változók. Ugyanúgy megszűnnek!

Aktuális paraméter (argument): a hívás helyén adott érték.

  • Híváskor a megadás sorrendje számít
  • Nem csak változó lehet, hanem konstans is

Fontos, hogy a függvény deklarációja helyén a paraméterlista megadásánál minden egyes paraméter elé oda kell írni annak típusát. Akkor is, ha azok egyformák. Ezért van a fenti függvény fejlécében double a, double b, és nem pedig double a,b, ami helytelen szintaktikailag.

12. Paraméterek – a klasszikus hiba

Mivel a paraméter is lokális változó, a függvényből visszatérve megszűnik létezni. Emiatt a paraméteren keresztül közvetlenül nem lehet visszaadni értéket! A lenti programban is a függvény nem a szum változót, hanem a szum változó tartalmának másolatát kapja csak meg, vagyis 0-t. A szamol() függvény szum nevű lokális változóba tényleg bekerül az összeg, de megszűnik a szamol()-ból visszatérés után!

#include <stdio.h>

void osszeg(int a, int b, int szum) {
    szum = a + b;  /* ??! */
}

int main(void) {
    int szum;
    
    szum = 0;
    printf("előtte: %d\n", szum);
    
    osszeg(5, 6, szum);
    printf("utána: %d\n", szum);
    
    return 0;
}

A probléma megoldására egy későbbi előadáson fogunk visszatérni.

13. Nagyobb program: deklarációk, definíciók

#include <stdio.h>

double kerulet(double a, double b); // deklaráció/prototípus

int main(void) {
   printf("%f", kerulet(2, 3.4));
   return 0;
}

double kerulet(double a, double b) { // definíció
   return 2 * (a + b);
}

  • A fordítónak a main()-nél tudnia kell, mit jelent a kerulet
  • Nevét, paraméterek számát és típusát, visszatérés típusát
  • Vagyis deklarálni kell használat előtt; definiálni ráér később is
  • A deklaráció kihagyása hiba!

A fenti példa azt is kiemeli, miért olyan fontos ez. A fordító minden függvényhívás helyén elvégzi a paraméterek ellenőrzését: a függvénynek csak olyan típusú paraméterek adhatóak, amelyeket a fejléce alapján vár. A main()-ben itt a hívás helyén egy érdekes dolog történik. A 2 konstans egy egész, ezért a típusa int – viszont a fordító tudja, hogy a kerulet() valós paramétert vár, ezért előbb elvégzi a 2→2.0 átalakítást. Fordított esetben, pl. egészet váró függvénynek 3.14-et adva először egy lefelé kerekítés történne, és a függvény végülis a 3-as számot kapná meg. Az ilyesmit általában figyelmeztető üzenettel jelzik is a fordítók, ugyanis a törtrészt ilyenkor elveszítjük, amit lehet, hogy nem szeretnénk.

A függvényt egyébként nem szükséges a main() függvény után definiálni, előtte pedig csak deklarálni. Az egész definíció áthelyezhető a main() függvény elé is. Ilyenkor külön deklarációra nincsen szükség, mert a definíció is tartalmazza a nevet és a típusokat. A lényeg az, hogy a függvényhívás helyén a függvény típusának már ismertnek kell lennie:

/* kiszámítja egy a,b oldalú téglalap kerületét */
double kerulet(double a, double b) { // definíció
   return 2*(a+b);
}

int main(void) {
   printf("%f", kerulet(2, 3.4));
   return 0;
}

Nagyobb programok esetén azonban nem ez a preferált.

14. A void típus: „semmilyen”

int beolvas_szam(void) {
    int sz;
    scanf("%d", &sz);
    return sz;
}
void kiir_szam(int szam) {
    printf("%d", szam);

    return;
}
x = beolvas_szam(); // A híváshoz ilyenkor is kell a ( )
kiir_szam(x);

  • Ha a függvénynek nincs paramétere, azt a void szóval jelöljük
  • Ha nincs visszatérési értéke, azt is
    • Ilyenkor a return önmagában áll: return;, vagy el is hagyható

Történelmi okokból egy függvény definíciójánál a void szó elmaradhat a paraméterlista zárójelei közül, pl. int main(). Ez kicsit mást jelent, így jobb, ha a void szót mindig kiírjuk.

15. Érték és mellékhatás

printf("%d\n", fakt(6));
printf("%d\n", fakt(6));
printf("%d\n", fakt(6));
720
720
720

Érték: a kifejezés értéke. A függvény kiértékelése: a függvény lefut, és a hívás helyén lévő kifejezésbe a visszatérési értéke behelyettesíthető.

printf("%d\n", rand());
printf("%d\n", rand());
printf("%d\n", rand());
8311623
2141262
16641798

Mellékhatás: a függvény valahol változást okoz. (Ezt mellékhatásnak nevezzük még akkor is, ha kifejezetten ez a célja a függvénynek!)


Általában igyekszünk olyan függvényeket írni, amelyeknek csak értéke vagy csak mellékhatása van. Ennek az elvnek neve is van: command-query separation. Eszerint kétféle függvény van. Az egyik fajta a parancsfüggvény (command), amelyet azért használunk, hogy hatása legyen. A másik fajtának kérdéseket teszünk fel (query), amely kiszámol valamit, de mellékhatása nincs. Ha ez a kettő keveredik, az könnyen összevisszasághoz, átláthatatlan programfelépítéshez és nehezen megtalálható hibákhoz vezet.

Ha a függvény értéke csak a paramétereitől függ, mindig ugyanaz kell legyen az eredmény. Ha van mellékhatása, ez nem biztos! Valahol valaminek történnie kell, hogy a rand() mindig mást ad… Ez a függvény például kell rendelkezzen valamiféle belső állapottal. Láthatóan a kimenete nem a bemenő paraméter függvénye, hiszen nincs is neki! Általában sem lenne sok értelme a void paraméterű vagy visszatérési értékű függvényeknek, ha nem lenne mellékhatásuk. Az ilyenek matematikai értelemben véve nem függvények már, de ennek ellenére C-ben így hívjuk őket.

Fontos: ha a specifikáció nem kéri a kiírást, akkor kifejezetten hibának számít, ha a függvény mégis ilyet tesz! Például kiírja a képernyőre az eredményt ahelyett, hogy visszatérne vele. Hadd döntse el a hívó, mit szeretne csinálni az eredménnyel!

16. Procedurális/hierarchikus programozás

Felülről lefelé tervezés (top-down design)

  • Tervezés egyszerűsítése: „oszd meg és uralkodj”
  • A programrészek közötti csatolások csökkentése

A funkcionális dekompozíció (functional decomposition) egy tervezési elv. A másik neve a felülről lefelé (top-down) elv. Lényege, hogy a problémát részfeladatokra bontjuk. Az egész rendszert, programot úgy tervezzük meg, hogy közben a részfeladatokat megoldottnak tekintjük. Az egyes részfeladatok megoldása közben így nem kell a többi részleteivel bajlódni. A részfeladatok ugyanúgy specifikálhatóak, mintha egy teljes programról lenne szó.


„Az egyik dolog, amit a programozásban meg kell tanulnunk, az az, hogyan hagyjuk figyelmen kívül a részleteket.”
– Gerald J. Sussman

Érdekesség: Gerald Jay Sussman amerikai programozó, matematikus, aki legtöbbet a mesterséges intelligenciával foglalkozik. Az ő nevéhez is fűződik a Structure and Interpretation of Computer Programs című könyv megírása is, amelyhez egy programozás alapjait bemutató tárgy is tartozott az MIT egyetemen. A fenti idézet az egyik előadásáról származik. A tárgyat egyébként a saját maguk által kifejlesztett programozási nyelvvel tanították, amelynek a neve Scheme.

A top-down tervezést „wishful thinking” néven mutatta be (kb. ábrándozó gondolkodás). Hiszen éppen ez a lényege: „Bárcsak lenne egy olyan függvény, amelyik megmondja egy számról, hogy prímszám-e… Mert ha igen, akkor milyen egyszerű lenne a feladatunk!” Sokszor ezzel a gondolkodásmóddal tudjuk szétválasztani a részfeladatokat.

17. Dekompozíció példa: Cesàro és a π

A következő feladat a felülről lefelé (top-down) tervezést szemlélteti. Azt kell látni, hogy minden lépésben csak egy kicsit lépünk a megoldás felé; a következő lépést pedig mindig megoldottnak tekintjük.

A megoldás
egyben:
cesaro.c

Ernesto Cesàro (olasz matematikus): válasszunk ki véletlenszerűen két egész számot. Annak a valószínűsége, hogy ezek relatív prímek, 6/π².


Feladat: írjunk programot, amely megbecsüli a π értékét!


Ez a következőképpen nézhet ki. Először is, rendezzük az egyenletet! Tegyük fel, hogy a keresett valószínűséget majd a később megírandó cesaro_valoszinuseg() függvény megmondja. Akkor a főprogram csak ennyi:

int main(void) {
    double pi;
    
    pi = sqrt(6.0 / cesaro_valoszinuseg());
    printf("pi = %f", pi);

    return 0;
}

Hogyan írjuk meg ezt a függvényt? Kérdés, hogyan számoljuk ki a valószínűséget. Tegyük fel, hogy adott egy cesaro_kiserlet() függvényünk, amely elvégzi a kísérletet (két véletlenszerűen választott…) A részleteivel ne foglalkozzunk, csak ennyit mondjunk egyelőre: térjen ez a függvény vissza igazzal, ha a kísérlet sikerült. Végezzük el ezerszer! A sikeres kísérletek számát 1000-rel osztva megkapjuk a becsült valószínűséget:

Monte-Carlo
módszer
/* P meghatározása kísérletezéssel */
double cesaro_valoszinuseg(void) {
    int db = 0;
    for (int i = 1; i <= 1000; i += 1)
        if (cesaro_kiserlet())     // elvégzi a kísérletet
            db += 1;
    return db / 1000.0;   /* egész osztás elkerülése! */
}

Mi a kísérlet? Az, hogy két véletlenszám relatív prím. Gyártsunk ehhez 1 és 1000 között véletlenszámokat, és hasonlítsuk a legnagyobb közös osztójukat 1-hez. Mert ha 1, akkor ezek relatív prímek, tehát sikerült a kísérlet, és ezért igazzal kell visszatérnünk:

/* A kísérlet: a legnagyobb közös osztójuk 1? */
bool cesaro_kiserlet(void) {
    return lnko(rand()%1000 + 1, rand()%1000 + 1)==1;
}
Részletkérdés csak: ezen a ponton a főprogramot ki kell egészítenünk egy srand(time(0)) függvényhívással, mert tudjuk, hogy a véletlenszám-generátort a program indulásakor inicializálni kell egyszer.

Már csak annyi a dolgunk, hogy két szám legnagyobb közös osztóját meghatározzuk. Ehhez Euklidész módszerét megnézhetjük a Wikipédián is. A pszeudokódot csak át kell írni C-be:

„Az euklidészi algoritmus minden algoritmusok nagyapja. Ez a legrégebbi nemtriviális algoritmus, amelyet mindmáig használunk.”
– Donald Knuth
/* visszatér a két szám legnagyobb közös osztójával. */
int lnko(int a, int b) {
    while (b != 0) {
        int t = b;
        b = a%b;
        a = t;
    }
    return a;
}

Érdekesség: Donald Knuth amerikai programozó. Leghíresebb műve a „Számítógépprogramozás művészete” (The Art of Computer Programming) című többkötetes könyv. Az ő nevéhez fűződik a TeX nevű szövegszedő program kifejlesztése is, amelynek különféle változatait most is használják könyvek, folyóiratok szerkesztéséhez, szedéséhez. Az elterjedt irodai programcsomagokkal készített dokumentumok külleme meg sem közelíti azt, ami a TeX segítségével elérhető!

Az eredmény

... és kész. A programot lefuttatva megkapjuk a π közelítő értékét.

3.131121
_

Struktúrák

19. Emlékeztető: típusok

Ismétlés
(1. előadás)

Típus: értékkészlet és hozzá tartozó műveletek.


Egyszerű, beépített típusok:

  • Egész számok: int, long int stb.
  • Lebegőpontos számok: float, double
  • Karakterek: char
  • Logikai: bool

Összetett, származtatott típusok:

  • Tömb: egyforma típusú elemek sorszámozott tárolója
  • Struktúra: összetartozó adatok – erről lesz most szó.

20. Hogyan tároljunk törteket?

Racionális számok

Tegyük fel, hogy egy olyan programot kell készítenünk, amely racionális számokkal dolgozik. Hogy ezeket pontosan tudjuk tárolni, a lebegőpontos tárolás ötletét elvetjük: mindig külön tároljuk a számlálót és a nevezőt, két egész típusú változóban. Ahogy írjuk a programot, azonban egyre bonyolultabb kifejezéseink lesznek; egyre nehezebb lesz követni, hogy melyik törtes műveletet mely változókon kell elvégezni. Pl. az alábbi művelet:

┌ a   c ┐   ┌ e   g ┐   ad+cb   eh+gf   (ad+cb)(eh+gf)
│ ─ + ─ │ · │ ─ + ─ │ = ───── · ───── = ──────────────
└ b   d ┘   └ f   h ┘     bd     fh          bdfh
Mi tartozik
össze?!

Kódban ez így nézne ki:

i = (a*d+c*b)*(e*h+g*f);
j = b*d*f*h;

Még ha be is vezetünk valami konvenciót a jelölésre (pl. asz és an az a tört számlálója és nevezője), akkor is elég reménytelennek tűnik a helyzet. Két tört számlálója és nevezője – ez se sokkal jobb:

int asz, an, bsz, bn; // a és b tört, számlálók és nevezők

Mi hiányzik nekünk? Az adat absztrakciója! Az hiányzik, hogy az adattípusokból ugyanúgy tudjunk építkezni, ahogyan az algoritmusoknál a függvényekkel is tettük. Legyen olyan nyelvi elem, amely segítségével több összetartozó adatot egységként kezelhetünk, és néven is nevezhetjük az így kialakult adatcsomagot.

21. Összetett típus: struktúrák

A struktúrák fogalma

szaml
─────
nev

A struktúrák arra jók, hogy egy új típust hozzunk vele létre. Ez a típus egyedi lesz, saját névvel fog rendelkezni a programban. A struktúrák segítségével összetett dolgokat (pl. racionális számokat) tudunk egyszerűbb, meglévő típusokkal (két egész szám, számláló és nevező) reprezentálni. A strukturák által összetartozó adatokat tudunk egységként kezelni.

  • Összetartozó adatok egységként
  • Új típus létrehozása
  • Absztrakt fogalom reprezentációja egyszerűbb típusokkal

A törteket egységként kezelve, struktúrával:

typedef struct Tort {
    int szaml, nev; // tört: számláló és nevező
} Tort;

Tort a, b, c, d, x; // mindegyiknél számláló, nevező

x = szorzat(osszeg(a, b), osszeg(c, d));  // (a+b)*(c+d)

A változók és típusok elnevezésénél érdemes figyelni a következetességre, mert az megkönnyíti a programok írását és megértését. Sok helyen szabályokat is alkotnak erre, amelyeket egy adott cégnél vagy programozói közösségnél szigorúan betartanak. Egyik ilyen elterjedt szokás az, hogy a saját típusok neveit nagybetűvel kezdik, a változókat pedig kicsivel. Ezért lett a tört struktúra neve a fenti példákban a nagybetűs Tort, az egyes példányok neve pedig a kisbetűs a és b.

22. Struktúrák: típus és változók létrehozása

Definíció szintaxisa

struct név { definíció
   T1 mező1, mező2, …;
   T2 mező3;
   …
};           pontosvessző!

Definíció és példányosítás

struct Tort {
    int szaml, nev;
};

struct Tort t1, t2;

Az egyes mezők deklarációjának szintaktikája megegyezik a változók deklarációinak szintaktikájával: Típus név;. Csak itt nem változó lesz belőlük, hanem egy struktúra adattagjai lesznek. T1, T2… bármilyen, már létező típusok lehetnek. A struktúra neve is bármi lehet, ami még nem foglalt. Hasonlóan, a mezők különböző nevűek kell legyenek – azonban az megengedett, hogy különböző struktúrák ugyanolyan mezőneveket tartalmazzanak. Pl. a Pont2D struktúra mezői lehetnek x és y, a Pont3D struktúra mezői pedig x, y és z.


Mezőkre (adattagokra) hivatkozás

t1 tört: 3/4
struct Tort t1;

t1.szaml = 3;  // a számlálója legyen 3
t1.nev = 4;

printf("t1 tört: %d/%d", t1.szaml, t1.nev);

A struktúra mezőkből áll, más néven: tagok vagy adattagok (member). Adott mezőre ponttal hivatkozunk: változó.mezőnév. Pl. t1.szaml jelentése: a t1 tört számlálója. Ebben t1 típusa struct Tort, t1.szaml típusa pedig int. Egy adattag teljesen ugyanúgy tud viselkedni, mint bármelyik másik változó: érték adható neki, kifejezésekben szerepelhet, printf() kiírja, scanf() beolvassa. Sajnos ez utóbbi függvények a struktúrát egészében nem tudják kezelni, csak az egyes beépített típusú adattagokat.

23. A typedef kulcsszó

A typedef kulcsszóval egy típusnak adhatunk új nevet:

typedef int Egesz; // meglévő név és új név
typedef char Betu;

Egesz x;           // x egész, vagyis int
Betu b;

A typedef kulcsszóval egy meglévő típusnak adhatunk egy új nevet. Olyan nevet érdemes adni, amelyik számunkra beszédesebb és jobban kifejezi az adott típus szerepét. Itt is hasonló a szintaktika, mint a változó deklarációjánál. Csak a névből nem változó neve lesz, hanem a típusnak egy másik neve.


Struktúráknál gyakran használjuk. Lássuk egy másik példán:

struct Pont {
    double x, y;
};
typedef struct Pont Pont;

Pont p;
/* röviden, egyszerre: */
typedef struct Pont {
    double x, y;
} Pont;

Pont p;

Mindkét forma ugyanazt jelenti.

A struktúrák esetén leginkább arra használjuk, hogy spórolni lehessen a gépeléssel: typedef struct Pont Pont után nem kell mindig kiírni, hogy struct Pont, elég annyit, hogy Pont. Lustaság, fél egészség. A jobb oldalt látható szintaktikával a struktúra definíciója és az új név megadása összevonható. Ilyenkor a struktúrának nem is lenne kötelező nevet adni, vagyis az első Pont szó elhagyható lenne. Ilyennel is gyakran találkozni C programokban. A struktúra maga ilyenkor névtelen (anonymous structure):

typedef struct {
    double x, y;
} Pont;

A struktúra neve (Pont), és a typedef segítségével adott név nem kötelezően egyforma. De ha nem így teszünk, csak összevisszaságot okozunk vele, úgyhogy érdemes úgy megadni, hogy egyformák legyenek.

24. Struktúrák kezdeti értéke

Struktúrák inicializálása

typedef struct Pont {
    double x, y;
} Pont;

Pont p1 = { 2, 5 };   // p.x = 2 és p.y = 5 lesz

Az egyes értékek a definíció sorrendje szerint meghatározott módon kerülnek a mezőkbe. Vigyázni kell, ha megváltoztatjuk a sorrendet!

Nagyon fontos megérteni az értékadás és az inicializálás közötti különbséget. Az inicializálás azt jelenti, hogy egy éppen definiálás alatt lévő változónak megadjuk a kezdeti értékét; az értékadás pedig az, hogy egy már létező, régebben létrejött változónak adunk valami új értéket. A kettő nem ugyanaz, csak mindkettőt szintaktikailag az egyenlőségjellel kell jelezni.


Struktúra típusú literális

Pont p2 = { .x = 2, .y = 5 };    // inicializálás

p2 = (Pont) { 2, 5 };            // értékadások
p2 = (Pont) { .x = 2, .y = 5 };

A C nyelv újabb (C99 szabvány utáni) változataiban ilyet is lehet írni. A fenti inicializálás egyébként kiváltható a .x és .y nélküli formával, ahol a definíció sorrendjét használjuk; a lentebbi értékadások pedig a mezőknek egyesével értékadással:

p2.x = 2;
p2.y = 5;

25. Használhatom, ahogy egy „sima” változót?

Értékadás

Pont p1, p2;
p1 = p2;

A struktúra értékadás minden mezőt másol: a fenti példában p1.x=p2.x; p1.y=p2.y;.

Függvény paramétere, visszatérési értéke

/* megadja a pont origótól mért távolságát */
double origo_tavolsag(Pont p) {
    return sqrt(p.x*p.x + p.y*p.y);
}

/* megadja a szakaszfelezőt */
Pont szakaszfelezo(Pont p1, Pont p2);
Pont a, b, c;
a = szakaszfelezo(b, c);
printf("%f", origo_tavolsag(a));

Struktúra lehet függvény paramétere és visszatérési értéke is. A paraméterátadás szabályai ugyanazok, mint az egyszerű típusoknál: ha változót adunk át, akkor a függvény csak a változó értékét fogja látni, az eredeti változót nem. Tehát nem tudja megváltoztatni azt.

26. Típusok láthatósága: lokális és globális

A típusokat általában globálisan adjuk meg: mindenhol látszódjanak.

/* globálisan */
typedef struct Pont {
    double x, y;
} Pont;


int fuggveny(void) {
    Pont p1, p2; // látható
}

int masik_fuggveny(void) {
    Pont p;      // itt is
}
/* lokálisan */
int fuggveny(void) {
    typedef struct Pont {
        double x, y;
    } Pont;

    Pont p1, p2; // látható
}

/* Ez így HIBÁS! */
int masik_fuggveny(void) {
    Pont p;     // ismeretlen!
}

A saját típusainkat definiálhatjuk lokálisan és globálisan. A típusok általában azért globálisak, mert a programunk adatai azokon belül több helyen is előkerülnek. Vagyis több függvényben is. Ennek ellenére természetesen lehetséges az, hogy egy adott típus csak egy függvényen belül létezik. Ha csak ott használjuk, akkor érdemes lokálisan megadni, mert akkor követhetőbb a program mások számára.

27. Törtes példa: összetett feladat

Racionális számok

A teljes
megoldás:
tort.c

Feladat: a C nyelv nem tartalmaz racionális szám típust. Hozzunk létre egyet! Írjuk meg az ezeket összeadni, szorozni, kiírni tudó programrészeket!


Megoldás

  • Ez új típus! Saját értékkészlet és műveletek!
  • Összetartozó adatok is. Ezért ez egy struktúra lesz!
  • A műveletek pedig függvények.



A törtek struktúrája

typedef struct Tort { // függvényen kívül: globális
    int szaml, nev;
} Tort;

int main(void) {
    Tort t1;           // a typedef miatt elég annyi, hogy Tort

    t1.szaml = 1;      // 1/2
    t1.nev = 2;

    return 0;
}

Mivel a struktúrát több függvény is használja, globálisan definiáljuk.

Tört kiírása

A printf() nem ismeri a tört típust, ezért a kiírást nekünk kell megoldanunk. Ezt szeretnénk:

Tort t1;

t1.szaml = 2;
t1.nev = 3;
tort_kiir(t1); // 2/3 jelenjen meg

A függvény nem tér vissza semmivel, csak kiírja a törtet.

/* Kiírja a törtet számláló/nevező alakban */
void tort_kiir(Tort t) {
    printf("%d/%d", t.szaml, t.nev);
}

Tört valós értéke

Szükségünk lehet a tizedes törtre is:

Tort x = {2, 3};

printf("%f\n", tort_valos(x)); // 0.666667

A függvény egy törtből csinál double típusú lebegőpontos számot.


/* Visszatér a tört lebegőpontos értékével */
double tort_valos(Tort t) {
    return (double)t.szaml / t.nev;
}

Vigyázni: ne egész osztást végezzünk! Különben 2/3 = 0.

Törtek összeadása

osszeg = tort_osszead(a, b);

A szorzat lehet közös nevező. Két törtet összegző függvény:

a   c   ad+cb
─ + ─ = ─────
b   d     bd
/* visszatér a két tört összegével */
Tort tort_osszead(Tort t1, Tort t2) {
    Tort uj;

    uj.szaml = t1.szaml*t2.nev + t2.szaml*t1.nev;
    uj.nev = t1.nev*t2.nev;

    return uj;
}

Törtek összeadása – eredmény?!

Itt tartunk most:

#include <stdio.h>

typedef struct Tort {
    int szaml, nev;
} Tort;

void tort_kiir(Tort t);
Tort tort_osszead(Tort t1, Tort t2);

int main(void) {
    Tort x = {1, 2}, y = {1, 4};

    tort_kiir(tort_osszead(x, y));

    return 0;
}

void tort_kiir(Tort t) {
    printf("%d/%d", t.szaml, t.nev);
}

Tort tort_osszead(Tort t1, Tort t2) {
    Tort uj;

    uj.szaml = t1.szaml*t2.nev
             + t2.szaml*t1.nev;
    uj.nev = t1.nev*t2.nev;

    return uj;
}

A program futási eredménye:

6/8

Ez helyes is, és nem is. Helyes, mert 6/8 az 3/4, és az összeg tényleg annyi. De lehetne jobb is, ha a program egyszerűsíteni is tudna.

Tört létrehozása – egyszerűsítve!

x = tort_letrehoz(50, 100); // 1/2

Nagyon fontos itt a függvény filozófiája. A két egész szám összerakva nem csak egyszerűen két egész szám együtt, hanem egy tört. Speciálisabb, mint egy sima számpár. Ezért amikor egy törtet „építünk”, azaz létrehozunk két egész számból, akkor el kell végeznünk egy egyszerűsítést rajta. Az egyszerűsített tört egyenértékű az összes bővített változatával. Innentől kezdve, hogy ez a függvényünk megvan, mindig ezt fogjuk használni akkor, amikor egy számlálóból és egy nevezőből létrehozunk egy törtet. Így minden törtünk egyszerűsítve lesz! Sőt aki a törtes függvényeinket használja, annak is azt javasoljuk, hogy minden törtet ezzel a függvénnyel hozzon létre, ne pedig struktúra inicializálással vagy pedig „kézi” értékadással külön a számlálónak és a nevezőnek. Így neki sem kell törődnie majd az egyszerűsítéssel.


/* Törtet hoz létre, egyszerűsítve */
Tort tort_letrehoz(int szaml, int nev) {
    Tort uj;
    int a = szaml, b = nev;
    while (b != 0) {    // Euklidész
        int t = b;
        b = a%b;
        a = t;
    }
    uj.szaml = szaml/a;
    uj.nev = nev/a;   // legnagyobb közös osztó = a
    return uj;
}

Az euklidészi algoritmus megkeresi két szám legnagyobb közös osztóját. Ezzel osztva a számlálót és a nevezőt megkapjuk az egyszerűsített törtet.

Törtek összeadása és szorzása – most már helyesen

/* műveletek törtekkel */
osszeg = tort_osszead(a, b);
szorzat = tort_szoroz(a, b);

Az összeadást és a szorzást megvalósító függvények:

/* Visszatér a törtek összegével. */
Tort tort_osszead(Tort t1, Tort t2) {
   return tort_letrehoz(t1.szaml*t2.nev + t2.szaml*t1.nev,
                        t1.nev*t2.nev);
}
a c   ac
─·─ = ──
b d   bd
/* Visszatér a törtek szorzatával. */
Tort tort_szoroz(Tort t1, Tort t2) {
    return tort_letrehoz(t1.szaml*t2.szaml,
                         t1.nev*t2.nev);
}

Az összeadás most már elvégzi az egyszerűsítést is, hiszen a törtet létrehozó függvény tartalmazza azt is. Egyszerűbb lett a függvény, hiszen a lokális változóra sincsen már szükség. Amit a tort_letrehoz() visszaad, azt passzolja is tovább a hívónak. A szorzás ugyanígy működik programozásilag, és a többi művelet: kivonás, osztás sem különböző.

Tört beolvasása

Olvassunk be egy törtet a billentyűzetről:

Írd be a törtet:
6/8_
Tort t;

t = tort_beolvas();

/* beolvas egy törtet a billentyűzetről, és visszaadja */
Tort tort_beolvas(void) {
    int szam, nev;
    scanf("%d / %d", &szam, &nev);
    return tort_letrehoz(szam, nev);
}

Mi történik, ha nem számot ír be? Ha 0 nevezőt ad?

Kérdés, mit csináljunk akkor, ha a billentyűzetről nem érvényes adat érkezik. Akár nincs a két szám között törtvonal, akár a felhasználó nem számot ír be, akár nullát ad meg nevezőnek – sok okból lehet helytelen az adat. Ha a függvényt a fenti formában írjuk meg, akkor mindenképpen vissza kell térnünk egy törttel (hiszen ez a függvény visszatérési értéke). Na de mi legyen ez a tört hiba esetén? 1/1? 0/0? Valamilyen módon a hibát jó lenne jelezni. 1/1 nem lehet a visszatérési érték, mert az egy helyes tört. A 0/0 talán jobb ötlet lenne.

A probléma igazából onnan gyökerezik, hogy a függvénynek nem egy, hanem két eredményt kell előállítania. Egy hibakódot (sikerült vagy nem sikerült), és magát a törtet. A fenti függvénynek pedig csak egy visszatérési értéke van. A következő előadáson bemutatott módszerrel lehetségessé válik majd több visszatérési érték adása egy függvényből.

(Többszörösen) összetett adatok

29. Struktúrák vs. tömbök I.

Könyv: struktúra

Struktúrába egy dolog összetartozó adatait tesszük.

  • Különálló, új típus, saját műveletekkel
  • Pl. egy könyv adatai: cím, szerző, oldalszám

Könyvek: tömb

Tömbben több egyforma dolog adatait tároljuk.

  • Ez csak egy tároló azonos szerepű dolgok számára.
  • Pl. könyvek katalógusa

Elfajulások lehetségesek: létezhet egy elemű struktúra vagy tömb is. Az, alma, a, körte: ezek szavak, még az „a” névelő is, hiába egy betűs!

Ne feledjük: a típus egy értékkészlet és műveletek együttese. Egy dátum struktúra létrehozásával egy új típust hozunk létre, amelyen új műveletek értelmezhetőek. Pl. ki lehet számolni két dátum között a különbséget napokban. Ez kizárólag csak a dátumokon értelmezett művelet (év, hónap, nap), nem pedig az összes háromelemű, egészekből álló tömbön!

Ha az összetartozó adatok különböző típusúak (pl. a név karaktersor, a dátum pedig egész számokból áll), akkor biztonsan struktúráról van szó. Ha egyformák a típusok, gyakran akkor is. Balgaság a tört számlálóját és nevezőjét nem struktúrával, hanem egy kételemű tömbbel megadni. Úgyszint egy év, hónap, napból álló dátum is inkább struktúra, bár mindegyik eleme egész szám. A tömb választása azt is éreztetné, hogy az év, hónap, nap felcserélhetőek, ami nem igaz. Egy névsor elemei, amelyet tömbben tárolunk, viszont igen: sorba rendezhetőek az emberek név, születési évszám, magasság stb. szerint is.

És még egy dolog, amit ne felejtsünk el: nem azért használunk tömböt vagy struktúrát, mert sok adattal dolgozunk, hanem azért, mert az adatoknak közük van egymáshoz! A tört számlálóját és nevezőjét is betettük egy struktúrába, pedig csak két elemről van szó. Ugyanígy, egy három betűből álló szó is tömb a programozás szempontjából. Sőt ha egyszer eldöntjük, hogy a szavakat karaktertömbökben tároljuk, akkor még az „a” névelő is egy tömb, az is egy szó! Mondhatjuk, hogy ha valamilyen adatoknak a tömbbe vagy struktúrába tevése által megszűnik a programkódban a „sorminta” (pl. a1=b1; a2=b2; a3=b3; helyett a=b lesz a struktúra értékadás által), akkor jó úton járunk. Ha „sorminta” van a programunkban, akkor pedig valószínűleg rossz úton. Az összetett típusokban az adataink közötti összefüggéseket rögzítjük, és ez kihatással van a programkód felépítésére is: annak áttekinthetőségére, egyszerűségére és legfőképp minőségére.

Néha van olyan eset, amikor nem teljesen egyértelmű, hogy struktúráról vagy tömbről van szó, ilyen a sok dimenziós tér esete is. Ha mindegyik komponenst kiírjuk, akkor rengeteg mezőnév keletkezik, amelyeket meg kell jegyeznünk. Ha tömböt használunk helyette, akkor az egyes komponenseket ciklussal dolgozhatjuk fel.

struct Pont10D {
   int x, y, z, a, b, c, d, e, f, g; // struktúra ennyi névvel?
};

int koord[10];                       // vagy inkább tömb?

A problémára talán a legjobb megoldás ez:

struct Pont10D {
   int koord[10];
};

Így ciklussal is fel lehet dolgozni a komponenseket, ugyanakkor egy Pont10D struktúrát lehet értékül adni, és függvénynek paraméterként is.

30. Struktúrák vs. tömbök II.

Értékadás

typedef struct Pont {
   double x, y;
} Pont;

Pont a, b;
a = b;           // OK!
int t1[100];
int t2[100];



t1 = t2;   // Hibás!

Függvény visszatérési értéke

Pont origo(void) {
    Pont pont = {0, 0};

    return pont;  // OK!
}
????? nullatomb(void) {
    int t[100] = {0};
    
    return t;   // Hibás!
}

Az értékadás a struktúrák között működik C-ben, a tömbök között nem. A függvényből visszatérés struktúrával működik C-ben, a tömbbel nem. Mindez azért van, mert a C nyelv szabályai szerint struktúrát lehet értékadással másolni, tömböt viszont nem.

31. Többszörös összetétel: definíciók sorrendje

Többszörösen összetett adatok esetén a definíciók sorrendjére figyelni kell: csak a már definiált típusokból lehet építkezni.


typedef struct Datum {
    int ev, honap, nap;
} Datum;

typedef struct Ember {
   char nev[100];
   char lakcim[150];
   Datum szuletesnap;
} Ember;

Ember e1, e2;
e1 = e2;           /* Így jó, a tömbök is másolódnak! */

Vagy egy geometriai programban, típusok:

typedef struct Pont {      // egy pont a síkban
   double x, y;
} Pont;

typedef struct Szakasz {
   Pont eleje, vege;       // szakasz két pont között
} Szakasz;

typedef struct Kor {       // középpont és sugár
   Pont kozeppont;
   double sugar;
} Kor;

Művelet példa:

/* igazzal tér vissza, ha egy pontban metszik egymást */
int metszi_e(Szakasz sz1, Szakasz sz2);

32. Sztringek és kétdimenziós tömbök

Szövegek reprezentálása: karaktertömb

char str[100] = "hello";
printf("A szöveg: [%s].", str); // A szöveg: [hello].

A szöveg karakterek sorozata: sztring (string), más néven karakterlánc. C-ben nincs külön típus, hanem karakterek tömbjeként adjuk meg. Mivel tömbről van szó, a méretét meg kell mondanunk előre. Az = értékadás operátor sem használható rajta. Következő előadáson részletesen szerepelni fog.


Kétdimenziós tömb: tömbök tömbje

double matrix[5][6];
matrix[sor][oszlop] = 3.14;
2D tömb: mátrix

Itt egy 5×6-os táblázatot adunk meg: 5 sor × 6 oszlop.

char tictactoe[3][3];
tictactoe[0][2] = 'o';
2D tömb: tic-tac-toe játék

A tic-tac-toe játékhoz 3×3-as játéktér kell.