Eddig a programjainkban egyszerű típusokat alkalmaztunk, azonban ezek nem mindig elegendőek. Például, ha egy pont adatait szeretnénk leírni, akkor kézenfekvő lenne, hogy ezeket egy összetett típusban tároljuk. Erre ad lehetőséget a struct:
struct
{
int x;
int y;
} pont;
A fenti kódrészlet a pont változó típusának egy olyan struktúrát ad, ami két egész számot tartalmaz. Azonban így a típus egy névtelen típus. Ha más változók esetén is szeretnénk használni ezt a típust, akkor a typedef utasítással ki kell egészítenünk:
typedef struct
{
int x;
int y;
} pont_t;
Ez az utasítássor definiál egy pont_t típust, amit ezt követően bárhol használni tudunk a programunkon belül úgy, mintha beépített típus lenne:
point_t pont;
Mivel a C nem objektumorientált, így nincs lehetőségünk konstruktor megadására, ami inicializálja a struktúra tagjait egy kezdőértékre, így erről magunknak kell gondoskodnunk.
A struktúráinkon is alkalmazhatjuk a sizeof() operátort, ami a struktúra elemeinek kombinált méretét adja vissza. Itt érdemes megjegyezni, hogy ha a struktúránk mutatót tartalmaz (pl char *), akkor a mutató által mutatott memóriaterület mérete nem számolódik bele a struktúra méretébe, csupán csak a mutató mérete.
Tárolási szempontból egy érdekes típus C-ben az únió. Ezt az union kulcsszóval definiáljuk. Az únióban elhelyezett változók osztott memóriaterületen helyezkednek el. Ez lehetővé teszi, hogy ugyanazt a memória területet többféleképpen kezeljük:
#include <stdio.h>
#include <stdint.h>
typedef union
{
uint32_t egesz;
uint8_t bytes[4];
} number_t;
int main()
{
number_t szam;
szam.egesz = 255;
for (int i=0; i<4; i++)
{
printf("%d ", szam.bytes[i]);
}
return 0;
}
A fenti példában a number_t egy olyan típust definiál, amiben az egesz változó és a bytes változó ugyanazon a memóriaterületen helyezkedik el. Az únió méretét a benne található legnagyobb méretű változó határozza meg. Jelen esetben ez 4 byte lesz, mivel az egesz és az ezt követő bytes tömb mérete is azonos.
Jelen esetben a bytes tömb értékének olvasása az egesz szám byte értékeit adja vissza. Az únió típus kifejezetten beágyazott rendszerek esetén hasznos, vagy akkor, ha ugyanazt az adatot több perspektívából is használhatóvá szeretnénk tenni anélkül, hogy extra memóriát foglalnánk.
A struktúrák és úniók esetén a tagokat a változó neve utáni pont karakterrel tudjuk címezni, abban az esetben, ha direkt hozzáférésünk van. Azonban ha egy pointeren keresztül van hozzáférésünk a változóhoz, akkor a tagokat a -> operátorral tudjuk kiválasztani:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct
{
char keresztNev[20];
char vezetekNev[20];
} person_t;
int main()
{
person_t *szemely = (person_t *)malloc(sizeof(person_t));
if (szemely == NULL) {
printf("Allokacios hiba");
return 1;
}
strncpy(szemely->keresztNev, "Elek", sizeof(szemely->keresztNev) -1);
strncpy(szemely->vezetekNev, "Teszt", sizeof(szemely->vezetekNev) -1);
printf("%s %s", szemely->vezetekNev, szemely->keresztNev);
free(szemely);
return 0;
}
A harmadik lehetőségünk összetett típus definiálásra az enum. Ez egy olyan típusdefiníció, amely az egész számokhoz rendelt elnevezett konstansok halmazának létrehozására használhatunk.
#include <stdio.h>
typedef enum
{
RED,
GREEN,
BLUE
} color_t;
int main()
{
color_t myColor = BLUE;
switch (myColor)
{
case RED:
printf("Piros.\n");
break;
case GREEN:
printf("Zöld.\n");
break;
case BLUE:
printf("Kék.\n");
break;
default:
printf("Unknown color.\n");
}
return 0;
}
A felsorolás értékeinek számozása itt is 0-tól indul, amit felülbírálhatunk explicit értékmegadással. A neveket, amiket használni szeretnénk a felsorolásban, csupa nagy betűvel adjuk meg, a név ütközések elkerülése érdekében. Ez azért fontos, mert mint a fenti példaprogramban is látható: a felsorolások nevesített értékeinél nem szerepel a felsorolás típus neve sehol.
Memóriaszervezés és kitöltés
A struktúrák kapcsán beszélnünk kell egy kicsit a számítógépünk memóriaszervezéséről. A számítógépünkben található processzor nem egyesével bitenként és nem is egyes byteonként olvassa a memóriát, hanem nagyobb egységekben, mégpedig azért, mert így hatékonyabban tud működni. Struktúrák esetén ez abban nyilvánul meg, hogy a legnagyobb méretű primitív adattípus határozza meg, hogy mekkora lesz a struktúra alignment-je, mivel ennyi adatot egyszerre be kell olvasnia a processzornak, hogy kezelni tudja az adatot. Nézzünk egy példát:
typedef struct
{
uint32_t a;
uint64_t b;
uint32_t c;
} A;
A fenti példában a struktúránk alignmentje 8 byte lesz, mivel az uint64_t a legnagyobb adattag. Viszont a meglepetés az, hogy ha lekérjük a struktúra méretét a sizeof() operátorral, akkor 16 byte helyett 24-et kapunk eredményül.
Ennek az oka az, hogy a processzor minden esetben a struktúra alignmentje által meghatározott byte-ot olvas minden lépésben, így kitöltő (padding) byte-okat illeszt be a fordító a struktúrába az a és c változó esetén.
A kitöltés sorrendjét a struktúrában a definíció sorrendje határozza meg. Az alábbi módosított példában ugyanazok a változók szerepelnek, csak más sorrendben definiálva:
typedef struct
{
uint32_t a;
uint32_t c;
uint64_t b;
} B;
Az alignment továbbra is 8 byte, de most a sizeof() 16 byte-ot ad vissza méretként 24 helyett, mivel nem kellett kitöltő byte-okat alkalmaznia a fordítónak.
Fejlettebb nyelvek esetén ez a memóriaoptimalizáció automatikusan megtörténik. Éppen ezért ha C#-ból lépünk interakcióba egy C kódrészlettel, akkor mindenképpen ki kell tennünk a [StructLayout(LayoutKind.Sequential)] attribútumot a struktúra defincíciójára, mivel ez arra utasítja a fordítót és a futtatókörnyezetet, hogy ne rendezze át automatikusan a memóriatartalmat a padding csökkentése érdekében.
A padding alkalmazása letiltható a fordítókban. Ennek akkor van értelme, ha fájlba írunk egy adatot. Ilyen esetben minden padding byte felesleges adat. GCC esetén a padding a __attribute__((packed)) módosítóval tiltható le:
#include <stdint.h>
#include <stdio.h>
/* Adatméret: 16 byte, de 24 byte lesz*/
typedef struct
{
uint32_t a;
uint64_t b;
uint32_t c;
} A;
/* Adatméret: 16 byte, és 16 byte lesz*/
typedef struct
{
uint32_t a;
uint32_t c;
uint64_t b;
} B;
/* Adatméret: 16 byte, packed */
typedef struct
{
uint32_t a;
uint64_t b;
uint32_t c;
} __attribute__((packed)) NoPadA;
int main()
{
printf("sizefof(A) = %lu\r\n", sizeof(A));
printf("sizefof(B) = %lu\r\n", sizeof(B));
printf("sizefof(NoPadA) = %lu\r\n", sizeof(NoPadA));
return 0;
}
A program kimenete:
sizefof(A) = 24
sizefof(B) = 16
sizefof(NoPadA) = 16
Itt felhívnám a figyelmet, hogy a packed módosítás egy olyan struktúra esetén, ami a memóriában fog elhelyezkedni és műveleteket végzünk vele, nem a legjobb ötlet, mégpedig azért, mert bizonyos architektúrák (pl. ARM) küszködnek a memóriaolvasással, ha az adat nem aligned formátumban van a memóriában.
x86 és x64 esetén is érdemes az aligned módra törekedni, mivel sokkal több optimalizációt tud így végrehajtani a fordító a processzor számára. Ennek az optimalizációnak és gyorsaságnak az elrontója a padding, amit ha meg tudunk szüntetni úgy, hogy aligned marad a memória (mint a korábbi példában átrendezéssel), akkor gyorsabb lesz a programunk.