Beleakadtam egy problémába, amikor is egy típusom adatmérete kritikussá vált. Egy kollekciót akartam a típusból készíteni, aminek mérete meghaladhatja a 10 vagy akár 100 milliót is.
Ebben a méretben már korán sem mindegy, hogy mekkora az típus adat, amit tárolunk a memóriában. Ha mondjuk egész számokat tárolunk egy byte
típusban és ebből veszünk 100 millió elemű tömböt, annak a mérete 100 millió byte lesz, tehát 100 megabyte. (Most az egyszerűség kedvéért számoljunk 1000-es szorzóval a byte, kilo-, mega-, és gigabyte mennyiségek közt.) Ezzel szemben ha az egész számot egy long
-ban tároljuk 8 byte-on, akkor az eredmény 800 MB, azaz majdnem egy giga memóriát elfoglaltunk csak egy számtömbbel.
Ha bár legtöbb esetben hidegen hagyhatja a mai programozót az elfoglalt memória mérete egy adott típusra remélem érzékelhető az is, hogy máskor, memóriakritikus környezetben azonban nem árt beleásni magunkat a bit-számolgatásba.
Beépített C# típusok mérete
Láttuk a fenti példán mekkora különbséget jelenthet bizonyos esetekben hogy byte
vagy long
típust használunk. Azért lehet persze mégis érdemes nagyobb méretű szám típust használni a kisebbel szemben, mert az adatmérettel nőni fog a típus értékkészlete is, azaz a felvehető értékeinek száma. Ha érdekel valakit, hogy mekkora minimum és maximum értéket vehetnek fel a különböző egész szám típusok, az utána olvashat itt, de persze nem túl nehéz ezt fejben sem kiszámolni, ha tudja a programozó, hogy hány biten tároljuk az adatot.
Egyész szám típus | Adatmennyiség |
byte , sbyte | 8 bit = 1 byte |
ushort , short | 16 bit = 2 byte |
uint , int | 32 bit = 4 byte |
ulong , long | 64 bit = 8 byte |
nuint , nint | Platformfüggő: Egy “szó” = 32 bit vagy 64 bit |
Mivel 2-es számrendszerben, biteken tárolunk el egy számot, amely bit 2 értéket vehet fel, ezért minden bittel duplázódik a felvehető értékek száma. Hogy összesen hány különböző értéket vehet fel egy típus kiszámolható tehát úgy, hogy egyszerűen vesszük a kettő olyan magas hatványát, ahány bitünk van az adat tárolására. Szóval például a 8 bites byte
típus , azaz 256 különböző értéket vehet fel, míg a 32 bites int
, azaz 4 294 967 296-et.
Ez persze még kevés, ahhoz, hogy a pontos értelmezési tartományt is kiszámoljuk. Tudni kell azt is, hogy az adott típuson megengedett-e a negatív számok használata. Ez C#-ban jól szabályozható az egész típusokra, ezért is van minden méretből kettő a fenti táblázatban. A típus elején lévő s
vagy u
betű árulja el, hogy a típus “Signed” vagy “Unsigned”, azaz magyarul előjeles vagy előjel nélküli. Minden típusból az előjeles verzió az alapértelmezett, kivéve a byte, ahol az előjelességet kell külön jelezni: sbyte
.
Ha a típus nem előjeles, akkor könnyű a helyzet, a szám egyszerűen felmehet 0-tól az összes felvehető érték száma mínusz 1-ig. Pl.: byte
: 0-255. Ha a típus előjeles, akkor a negatív és pozitív tartománynak osztozni a kell a biteken, tehát a számegyenes mindkét oldalának nagyjából fele jut az összes felvehető értéknek. Azért nem pont a fele, mert a negatív tartományban mindig eggyel tovább mehetünk. Gondoljuk végig: pozitív irányban nem mentünk el 256-ig, csak 255-ig, hiszen egy értéket már elhasznált a 0. Erre a “spórolásra” nincs szükség negatív tartományban, hiszen nincsen olyan szám, hogy negatív nulla. Tehát az egészek így néznek ki C#-ban:
Típus | Előjel | Adatmennyiség | Minimum érték | Maximum érték |
Előjel nélkül:
Előjeles: | Előjel nélkül:
Előjeles: | |||
byte | ⛔ | 8 bit = 1 byte | 0 | = 255 |
sbyte | ✅ | 8 bit = 1 byte | = -128 | = 127 |
ushort | ⛔ | 16 bit = 2 byte | 0 | = 65 535 |
short | ✅ | 16 bit = 2 byte | = -32 768 | = 32 767 |
uint | ⛔ | 32 bit = 4 byte | 0 | = 4 294 967 295 |
int | ✅ | 32 bit = 4 byte | = -2 147 483 648 | = 2 147 483 647 |
ulong | ⛔ | 64 bit = 8 byte | 0 | = Jóóóóó sok |
long | ✅ | 64 bit = 8 byte | = Minusz jó sok | = Jó sok |
(Annak módját, hogy pontosan hogyan használjuk a biteket arra, hogy eltároljunk velük különböző adatokat gépi számábrázolásnak hívjuk. A mai számítógépek túlnyomó része az úgynevezett kettes komplemens számábrázolást használja egészekhez, aminek nagy előnye, hogy ugyanazt a processzori áramkört tudja használni előjeles és előjel nélküli típusokhoz is az összeadásra és kivonásra. Ennek részleteit itt nem tárgyaljuk)
A törtek tárolására használatos lebegőpontos ábrázolást sem vesszük át alaposan. Most elég annyit tudni, hogy a C# Három primitív tört szám típust kínál 4, 8 és 16 byte-os adatmennyiségekkel. Mindegyik igen magas negatív és pozitív értékeket vehet fel, de minden esetben az ábrázolt pontosság csökken a számok magasságával.
Tört szám típusok | Előjeles | Adatmennyiség | Ábrázolás | Számrendszer |
float | ✅ | 32 bit = 4 byte | Lebegőpontos | Bináris |
double | ✅ | 64 bit = 8 byte | Lebegőpontos | Bináris |
decimal | ✅ | 128 bit = 16 byte | Lebegőpontos | Decimális |
Bővebb információk a C# tört szám típusokról itt!
Végül két egyéb menedzseletlen primitív típus maradt még hátra, aminek a méretéről nem beszéltünk:
bool
: 1 byte
char
: 2 byte (Unicode UTF-16)
Feltűnhet, hogy a bool
típus egy egész byte-ot elfoglal, mikor nyilvánvalóan elég lenne neki 1 bit is. Ennek optimalizációs oka van, hisz byte-okat tudunk egyedileg címezni, tehát a byte-ok elérése a memóriában egy elemi művelet. Habár ezáltal valamivel gyorsabb hozzáférni byte-onként az adathoz, mint bitenként, de ha nagyon memóriakritikus a környezet amiben dolgozunk, 8 bool
-t is belekódolhatunk egy byte-ba. Ha erre van szükségünk, akkor érdemes lehet utánanézni a bitműveleteknek.
Struktúrák
Ezzel átnéztük a C# beépített menedzseletlen típusainak méretét, én azonban nem számokat akartam egyedileg tárolni, hanem egy összetett adatszerkezetet, egy struktúrát, ami 4 számot tartalmazott. Ezen különböző számokat természetesen különböző célokra használtam, szóval az értelmezési tartományuk is különböző volt. Volt köztük olyan, amire elég volt egy byte
is és volt amire egy egész int
kellett.
Szerettem volna tudni, hogy fogok elférni a memóriában a magam milliós nagyságrendű tömbjével, ezért lekérdeztem a típus pontos méretét. Erre C#-ban használható a nameof(MyType)
kulcsszó, ami byte-okban adja vissza a típus hosszát. Sajnos ez a parancs összetett típuson csak menedzseletlen környezetben működik, azaz, ha kikapcsoljuk a szemétgyűjtőt az unsafe
kulcsszóval.
unsafe
{
int size = sizeof(TestStruct);
}
Ha ezt el szeretnénk kerülni, használhatjuk a Marshal osztály SizeOf
függvényét is, ami azonban csak az objektumok menedzseletlen méretét adja vissza. (Később arról, hogy mi az a menedzseletlen méret.)
int size = System.Runtime.InteropServices.Marshal.SizeOf(typeof(MyType));
Ez struktúrákra és szám típusokra tökéletesen működik, de én a biztonság kedvéért inkább unsafe
kódot használtam a kísérletezés alatt.
Mivel spóroltam a bitekkel, az én típusom egy int
-et, egy ushort
-ot és két byte
-ot tartalmazott. Ez összesen 8 byte mennyiségű adatra jött ki nekem: . Erre a mennyiségre is számítottam, azonban nagy szerencse, hogy ellenőriztem, hiszen meglepetés ért: Kiderült, hogy a fenti típus 12 byte-os. Még nagyobb meglepetés volt, mikor csökkenteni tudtam az adatméretet a várt 8-as byteszámra pusztán azzal, hogy átrendeztem a mezőket:
public struct MyType
{
public int a; // 4 byte
public byte b; // 1 byte
public ushort c; // 2 byte
public byte d; // 1 byte
}
// 12 byte
public struct MyType
{
public int a; // 4 byte
public byte b; // 1 byte
public byte d; // 1 byte
public ushort c; // 2 byte
}
// 8 byte
Szóval a kérdés felkeltette az érdeklődésem. Hogyan jön ki a C#-ban a típusok mérete pontosan. Mivel a hivatalos C# dokumentációban nem találtam semmi erre vonatkozó információt, nekiálltam kísérletezni. Nagyjából fél órás próbálgatás után összeállítottam egy kis adatbázist magamnak, amiben benne volt, hogy milyen típusokat tartalmazó struktúrák mennyi byte-ot foglalnak.
Mindezek alapján a következő szabályokat tudtam felállítani:
- A teljes struktúra mérete osztható lesz a benne lévő legnagyobb elem méretével. A legnagyobb elem mérete határozza meg a struktúra “csomagméretét”
pl:
byte
+byte
+byte
- Csomagméretét: 1 byte pl:int
+byte
+byte
- Csomagméretét: 4 byte pl:int
+byte
+long
- Csomagméretét: 8 byte - A fordító előlről, felülről-lefele haladva olvassa be a változókat és...
- Megpróbálja betenni az elemeket külön csomagba.
pl:
byte
+byte
+byte
⇒ 3db 1 byte-os csomag = 3 byte pl:int
+byte
+byte
+byte
+byte
⇒ [4] + [1+1+1+1] = 2db 4 byte-os csomag = 8 byte - Ha Egy elem nem fér bele az előző csomagba, nyit egy újat.
pl:
byte
+int
⇒ [1] + [4] = 2db 4 byte-oscsomag = 8 byte pl:byte
+byte
+int
⇒ [1 + 1] + [4] = 2db 4 byte-os csomag = 8 byte pl:byte
+int
+byte
⇒ [1] + [4] + [1] ⇒ 3db 4 byte-os csomag = 12 byte pl:byte
+byte
+long
+int
+int
+byte
⇒ [1+4] + [8] + [4+4+1] = 3db 8 byte-os csomag = 24 byte
Ha érdekel átnézheted az eredményeimet, hátha te más következtetésre jutsz mint én:
Menedzseletlen és menedzselt méret
Miért struct
-ot használtam és miért nem class
-t? Ennek az oka, hogy a struktúrák mindig érték típusúak és mindaddig menedzseletlenek, amíg nem tartalmaznak menedzselt típusú mezőt. Ezzel szemben az osztályok mindig menedzseltek és referenciatípusúak.
(A referenciákról és értékekről alapszinten itt írok: Referencia- és értéktípusok.)
Az hogy egy típus referencia alapú, az azt jelenti, hogy az objektumból nem készül egy új példány, mikor paraméterként átadjuk egy metódusnak vagy másoljuk egy másik változóba, helyette a objektumra mutató pointer segítségével érjük el az objektumot.
Mindez azt jelenti, hogy referenciatípus esetén egy változó és a benne tárolt objektum nem feleltethető meg egymásnak egy az egyben, míg érték típusok esetén igen. Emiatt, ha egy érték típusú változó megszűnik létezni, akkor a benne tárolt adatot is törölheti automatikusan a fordító. Tehát például ha egy kódblokkban létrehozunk egy értékváltozót benne egy érték típusú objektummal, és kilép a kód futása az adott blokkból, akkor a C# automatikusan fel is szabadíthatja a helyet, ahol az értéket tárolta. Ez így könnyű, gyors és egyszerű. Ezzel szemben ha egy referenciatípusú változó szűnik meg, akkor a fordító nem szabadíthatja fel a memóriát, amin a változóban tárolt objektum van, hisz nem tudhatja, hogy bárhol máshol a kódban van-e egyéb referenciánk erre az objektumra, amit akár még szeretnénk használni a jövőben. Ezen típusú objektumokat nevezzük menedzseltnek, azaz a garbage collector feladata a törlésük.
Minden referenciatípus menedzselt tehát és majd minden érték típus menedzseletlen. Utóbbi alól az egyedüli kivételek azon struktúrák, amik tartalmaznak menedzselt field-eket. Ezen struktúrák egyszerre menedzseltek és értéktípusok.
Egy struct
mérete tehát az őt alkotó mezőktől függ és a fent ismertetett szabály alapján számolható ki. Ezzel szemben egy referencia típusú class
-nak két mérete is van egyik az, hogy mennyi hely kell a memóriában egy objektum tárolására. Ez pontosan megegyezik azzal, ami egy struct
-nak kéne. Ez az objektum menedzseletlen mérete. Emellett más az a méret, amit egy referencia típusú változó tárol, ez ugye nem az adat maga, csak az adatot tartalmazó memóriaterület kezdőindexe.
Hogy ez mekkora, hány bites azt az dönti el, hogy mennyi memóriát tud a számítógépünk megcímezni. Egy 32 bites rendszer például ezt a 32 bitet használhatja arra, hogy byte-onként hivatkozzon az adat helyére a memóriában. Ez összesen byte, ami valamivel több, mint 4 GB. Mára még egy olcsóbb mobilon is 64 bites rendszer fut, de nem volt az olyan rég, mikor még figyelni kellett arra, hogy 32 vagy 64 bites Windows-t telepít az ember függően attól, hogy 4 GB memóriánál többet akarunk használni vagy sem. Ha eddig nem tudtátok volna, akkor ez volt az oka mindennek. Tehát az, hogy egy C# változó mekkora helyet használ el egy referencia tárolására, az attól függ, milyen rendszeren fut a kód. Lehet 32 vagy 64 is.
Egy összetett típus mérete tehát a menedzselt és a menedzseletlen méretéből áll össze. Utóbbi fix, míg előbbi rendszer-függő.
Marosi Csaba
+36 20 359 74 22
Ha érdekel a kódolás, játékfejlesztés fontold meg a jelentkezést egyik tanfolyamomra→