8.1 malloc, calloc, free és allokációs szerződés

A dinamikus memóriafoglalás nem egyszerűen „hely kérése a heapen”. A C program szempontjából új tárolási terület keletkezik, amelynek méretét, használatát és felszabadítását a programnak kell helyesen kezelnie. A sikeres foglalás önmagában nem jelenti azt, hogy a tároló már érvényes alkalmazásszintű adatokat tartalmaz.

Ebben az egységben négy külön kérdést követünk: mekkora tároló szükséges, sikerült-e lefoglalni, mely részei inicializáltak, és ki köteles felszabadítani. Az egészaritmetikai előfeltételeket a Promóciók, signedness és túlcsordulás, a típusos hozzáférés szabályait az Effective type, aliasing és objektumreprezentáció tárgyalta.

A példák ISO C17-et használnak. A konkrét laborprofil Linux, x86-64 System V ABI, LP64, 8 bites C bájt, GCC 14.2.0 és glibc 2.40. A fordítási parancs reprodukciós útmutató; a példához itt nem tartozik végrehajtott fordítás vagy mérés.

8.1.1 Az allokáció alapvető szerződése

ISO C17. A <stdlib.h> fejlécben deklarált malloc(size) egy size C bájt méretű területet foglal. A C bájt a sizeof egysége; bitszáma CHAR_BIT, nem általánosan nyolc.

Pozitív méret esetén a visszatérés:

A malloc nem inicializálja az adatokat. A tárolt értékek indeterminate állapotúak: nem tekinthetők használható kezdőértéknek, és típusos olvasásuk nem definiált viselkedéshez is vezethet.

A calloc(count, element_size) count darab, egyenként element_size C bájt méretű elemhez foglal tárolót, majd annak minden bitjét nullára állítja. Pozitív paraméterek mellett itt is ellenőrizni kell a null pointer visszatérést.

A malloc és a calloc eredménye void *, amely C-ben megfelelő objektumpointerré implicit módon konvertálható. Ezért a helyes idiomatikus alak:

/* ISO C17; részlet, <stdlib.h> szükséges.
   Előfeltétel: count pozitív, a szorzás nem csordul túl. */
int *p = malloc(count * sizeof *p);

Nem szükséges (int *) cast. A fejléc viszont kötelező: a cast nem pótolja a függvény megfelelő deklarációját.

A sizeof *p a célobjektum méretét fejezi ki. Ha később p típusa változik, a méretkifejezés követi. Ebben a példában az operandus nem változó hosszúságú tömbtípus, ezért a sizeof nem értékeli ki a *p kifejezést: nem történik dereferencia.

A szokásos foglalófüggvények megfelelő igazítást biztosítanak a fundamentális igazítási követelményű típusokhoz, például az itt használt int típushoz. Ez nem korlátlan ígéret minden kiterjesztett igazításra; azt az Alignment és speciális allokációk rész tárgyalja.

Operációs rendszer szintje. A sikeres foglalásból nem következik, hogy ugyanennyi fizikai memória azonnal rezidenssé vált. Hasonlóan, a free sem ígéri a folyamat RSS-ének azonnali csökkenését. Ezeket ne keverjük össze a C nyelvi élettartam-szerződésével.

8.1.2 Méretszámítás: túlcsordulás és nulla elemszám

Legyen nn az elemszám, s=sizeof(*p)s=\operatorname{sizeof}(*p) az elemméret C bájtban, M=𝚂𝙸𝚉𝙴_𝙼𝙰𝚇M=\texttt{SIZE\_MAX} pedig a size_t legnagyobb értéke. A szükséges méret:

B=n⋅s. B=n\cdot s.

Mivel s>0s>0, a szorzat pontosan akkor reprezentálható size_t értékként, ha

n≤⌊Ms⌋. n\leq \left\lfloor\frac{M}{s}\right\rfloor.

Bizonyításvázlat. A reprezentálhatóság feltétele ns≤Mns\leq M. Pozitív ss mellett oszthatunk vele; az egész nn felső korlátját a hányados egészrésze adja. Emiatt az ellenőrzést szorzás nélkül is elvégezhetjük:

/* ISO C17; részlet, <stdint.h> szükséges a SIZE_MAX makróhoz. */
if (count > SIZE_MAX / sizeof *p) {
    /* A kért méret nem reprezentálható. */
}

A malloc(count * sizeof *p) hívás önmagában nem védi ki a hibát. A szorzás már a függvény meghívása előtt megtörténik. A size_t előjel nélküli aritmetikájának körbefordulása definiált, de az így kapott túl kicsi foglalás későbbi túlindexeléshez vezethet.

A megadott x86-64 laborprofilban a size_t 64 bites, az int mérete négy C bájt. Ekkor:

⌊264−14⌋=262−1. \left\lfloor\frac{2^{64}-1}{4}\right\rfloor=2^{62}-1.

A 2622^{62} elemszámhoz tartozó szorzat már 2642^{64} lenne, amely ebben az előjel nélküli típusban nullára fordul körbe. A számítás helyessége természetesen nem garantálja, hogy egy kisebb, reprezentálható kérés teljesíthető.

Ha az elemszám előjeles külső adatból származik, a negatív értéket a size_t-ra konvertálás előtt kell elutasítani.

8.1.2.1 Az üres tömb legyen API-döntés

ISO C17. Nulla méretű foglalásnál implementáció által meghatározott, hogy null pointert vagy más eredményt kapunk-e. A visszakapott nem null pointert sem használhatjuk objektum elérésére; megfelelően felszabadítani viszont lehet.

Egyszerűbb saját szerződést választani:

Nulla elemszám esetén a művelet sikeres, az eredmény NULL, és nem történik allokáció.

Ez megkülönbözteti az üres eredményt az allokációs hibától, ezért a függvény külön státuszkódot ad vissza. A reprezentációhoz ne képezzünk automatikusan NULL + 0 kifejezést sem; üres tartománynál nincs szükség pointeraritmetikára.

A calloc(count, sizeof *p) előnye, hogy nem a hívó szorozza össze előre az argumentumokat. A saját méretellenőrzés mégis hasznos: elkülöníti a reprezentálhatatlan méretet az erőforráshiánytól, és egyértelmű API-szerződést ad. A calloc(1, count * sizeof *p) alak ezt az előnyt elveszíti.

8.1.3 Teljes példa: foglalás és logikai inicializálás

Az alábbi függvény initial_value értékekkel feltöltött tömböt készít. Az out egy kölcsönzött hozzáférés a hívó pointerobjektumához.

Előfeltétele, hogy out élő, írható int * objektumra mutasson, amelynek kezdeti értéke NULL. Ez kizárja egy korábban birtokolt foglalás csendes felülírását. Sikertelenségkor az objektum változatlan marad; siker esetén a hívó kapja meg a felszabadítás felelősségét.

Az allocation.c teljes tartalma:

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>

enum array_status {
    ARRAY_OK,
    ARRAY_TOO_LARGE,
    ARRAY_NO_MEMORY
};

static enum array_status
int_array_create(size_t count, int initial_value, int **out)
{
    int *p = NULL;

    if (count == 0) {
        return ARRAY_OK;
    }

    if (count > SIZE_MAX / sizeof *p) {
        return ARRAY_TOO_LARGE;
    }

    p = malloc(count * sizeof *p);
    if (p == NULL) {
        return ARRAY_NO_MEMORY;
    }

    for (size_t i = 0; i < count; ++i) {
        p[i] = initial_value;
    }

    *out = p;
    return ARRAY_OK;
}

int main(void)
{
    const size_t count = 4;
    int *values = NULL;

    enum array_status status = int_array_create(count, 7, &values);

    if (status != ARRAY_OK) {
        const char *message =
            status == ARRAY_TOO_LARGE
                ? "A kert meret nem abrazolhato."
                : "A memoriafoglalas sikertelen.";

        fprintf(stderr, "%s\n", message);
        return EXIT_FAILURE;
    }

    for (size_t i = 0; i < count; ++i) {
        printf("%d\n", values[i]);
    }

    free(values);
    values = NULL;
    return EXIT_SUCCESS;
}

Fordítás a megadott profilban:

gcc -std=c17 -Wall -Wextra -Wpedantic -Wconversion -Wshadow \
    -O1 -g allocation.c -o allocation

Sikeres foglalás és zavartalan kimeneti I/O esetén a várt kimenet négy sor, mindegyikben 7. Ez várt viselkedés, nem közölt futtatási eredmény.

A műveletek sorrendje lényeges. Először ellenőrizzük a méretet, majd tárolót foglalunk. A típusos értékadás minden elembe érvényes int értéket ír. A kimeneti pointert csak a teljes inicializálás után állítjuk be.

8.1.3.1 Tároló, effective type és logikai állapot

ISO C17. A dinamikusan foglalt tárolóhoz nem tartozik deklarált típus. Az int * pointer deklarálása vagy a foglalási eredmény hozzárendelése önmagában nem inicializálja a tárolót. A példában az int lvalue-kon keresztüli írás határozza meg az érintett objektumok effective type-ját az írás és a megfelelő későbbi hozzáférések számára.

Az effective type teljes szabályrendszere ennél részletesebb. Különösen a memcpy nem azonos egy tetszőleges bájtonkénti feltöltéssel; ezt az Effective type, aliasing és objektumreprezentáció, illetve a Memóriamásolás, összehasonlítás és byte view kapcsolja össze.

A tulajdonlási és élettartam-szerződés:

Résztvevő Szerep Érvényesség és megszűnés
Lokális p Sikeres foglalás után ideiglenes tulajdonos A függvény visszatéréséig él; a felelősséget előtte átadja
Allokált tároló Az elemek tárolási területe Sikeres foglalástól a free-ig él
values Sikeres visszatérés után tulajdonos A main blokkban él; egyszer felszabadítja a tárolót
out Kölcsönzött hozzáférés a values pointerobjektumhoz Csak a függvényhívás alatt használjuk, nem tároljuk el
Például &values[1] Lehetséges kölcsönzött elempointer Csak elegendő elemszám és élő tároló mellett érvényes; a free érvényteleníti

Az out előfeltételének megsértésére a függvény nem ad külön hibakódot: az szerződésszegés, amely nem definiált viselkedéshez vezethet. A mérethiba és az allokációs hiba viszont szabályosan jelentett hibakimenet.

8.1.4 Mit inicializál a calloc?

A minden bitjén nulla reprezentáció az egész típusoknál a nulla értéket adja. Nem általános viszont, hogy ugyanez null pointert vagy lebegőpontos nullát jelent.

A következő, ISO C17-es függvényrészlet az előző program fejléceit és enum array_status definícióját használja. Ugyanaz az out-szerződése, de kizárólag nulla értékű int elemeket állít elő:

static enum array_status
zero_int_array_create(size_t count, int **out)
{
    int *p = NULL;

    if (count == 0) {
        return ARRAY_OK;
    }

    if (count > SIZE_MAX / sizeof *p) {
        return ARRAY_TOO_LARGE;
    }

    p = calloc(count, sizeof *p);
    if (p == NULL) {
        return ARRAY_NO_MEMORY;
    }

    *out = p;
    return ARRAY_OK;
}

Itt a nullázott reprezentáció elegendő a kívánt egész kezdőértékekhez. Más alkalmazásszerződésben nem feltétlenül: ha egy int tömb érvénytelen file descriptorait -1 jelöli, a calloc eredménye logikailag hibás kezdőállapot, mert a nulla érvényes descriptorérték lehet.

A nullázás továbbá nem helyettesíti az elemszám, a kapacitás vagy más invariáns nyilvántartását. Ha minden elemet rögtön más értékkel írunk felül, a malloc általában világosabban fejezi ki a szándékot. Teljesítménykülönbséget mérés nélkül ne állítsunk.

8.1.5 A free pontos előfeltételei és a gyakori hibák

ISO C17. A free argumentuma lehet null pointer; a free(NULL) nem végez műveletet. Egyébként olyan pointerérték szükséges, amely megfelelő memóriafoglaló függvényből származik, és az általa jelölt foglalást még nem szabadították fel. A jelen egységben ezek a malloc és a calloc.

Nem szükséges ugyanazt a pointerváltozót használni, amelybe az eredmény eredetileg került: egy szabályosan másolt kezdőpointer is megfelelhet. A felszabadítás felelősségének azonban egyértelműnek kell maradnia. A C típusrendszere ezt az egyszeri tulajdonlást nem kényszeríti ki.

Nem adható át:

Az alábbiak egymástól független, hibás ISO C17-részletek. Kezdetben p legalább két inicializált int elem sikeres foglalásának tulajdonosa. Egyikhez sem tartozik garantált kimenet.

/* Interior pointer free: nem a foglalás kezdete. */
free(p + 1);
/* Double free: ugyanazt a foglalást másodszor szabadítaná fel. */
free(p);
free(p);
/* Use-after-free: az objektum élettartama már véget ért. */
free(p);
int value = p[0];

A free nem nullázza a hívó pointerváltozóját. A megszűnt objektumra hivatkozó pointerértéket nem szabad további hozzáféréshez használni; hordozható C17 kódban annak kiolvasására vagy összehasonlítására se építsünk.

A még élő pointerobjektumnak új érték adható:

/* ISO C17; részlet. p élő lokális pointerobjektum. */
free(p);
p = NULL;

Ez nem javítja meg a többi aliast. A tárolóra korábban kapott minden kölcsönzött hozzáférés érvénytelenné válik.

A másik gyakori hiba a kezdőpointer elvesztése: ha a tulajdonos változót felülírjuk, és másutt nem őrizzük a kezdőcímet, a tároló felszabadíthatatlanná válhat. Ez memóriaszivárgás. A megoldás nem egy utólagos cast, hanem a tulajdonosi felelősség megőrzése.

8.1.6 Feladatok

8.1.6.1 Magyarázó feladat: három külön állapot

Mi a különbség a malloc sikeres visszatérése, a calloc nullázása és az alkalmazásszintű inicializálás között? Miért kell külön státusz a nulla elemszámú tömbhöz, ha annak reprezentációja NULL?

8.1.6.2 Kódolási feladat: hely egy záró bájtnak

Írj byte_buffer_create(size_t payload, unsigned char **out) függvényt az előző státuszkódokkal. Foglaljon payload + 1 C bájtot, és az utolsóba írjon nullát. A payload többi bájtját ne inicializálja.

Az out kezdeti értékére ugyanaz a szerződés vonatkozzon, mint korábban. Nulla payload esetén is kell egyetlen bájt.

8.1.6.3 Diagnosztikai feladat: tulajdonos és alias

Független, hibás ISO C17-részletben base két inicializált int elem foglalásának tulajdonosa:

int *cursor = base + 1;
free(base);
base = NULL;
printf("%d\n", *cursor);
free(cursor);

Azonosítsd a hibákat, és adj javított műveleti sorrendet. A cursor eredetileg kölcsönzött pointer, nem tulajdonos.

8.1.7 Megoldások

8.1.7.1 A foglalás nem kész alkalmazásállapot

A malloc tárolót biztosít, de használható kezdőértékeket nem. A calloc a reprezentáció bitjeit nullázza. A logikai inicializálás az alkalmazás által megkövetelt érvényes állapot létrehozása; ez adott esetben lehet nulla, máskor például -1.

A NULL önmagában nem különbözteti meg az üres sikeres eredményt az allokációs hibától.

Ellenőrzési feltétel: a nulla elemszámú hívás eredménye ARRAY_OK és NULL; az erőforráshiányé ARRAY_NO_MEMORY.

8.1.7.2 Az összeadás ellenőrzése

ISO C17-részlet, az előző program fejléceivel és státusztípusával:

static enum array_status
byte_buffer_create(size_t payload, unsigned char **out)
{
    if (payload == SIZE_MAX) {
        return ARRAY_TOO_LARGE;
    }

    size_t count = payload + 1;
    unsigned char *p = malloc(count * sizeof *p);

    if (p == NULL) {
        return ARRAY_NO_MEMORY;
    }

    p[payload] = 0;
    *out = p;
    return ARRAY_OK;
}

A sizeof(unsigned char) definíció szerint egy C bájt, ezért itt az összeadás jelenti a túlcsordulási kockázatot. A payload tartalmát a hívónak kell meghatároznia használat előtt. Siker esetén a hívó a tulajdonos; a free minden ide mutató kölcsönzött pointert érvénytelenít.

Ellenőrzési feltétel: payload == 0 esetén egy nulla bájt keletkezik; payload == SIZE_MAX esetén nincs foglalás, a kimenet változatlan. Sikernél a hívó egyszer felszabadítja az eredményt.

8.1.7.3 Az alias nem követi a tulajdonos nullázását

A free(base) megszünteti az allokált objektumok élettartamát. A base = NULL csak a base pointerobjektumot módosítja; a cursor nem válik biztonságossá. Dereferálása use-after-free, felszabadítása pedig szintén hibás. Élő foglalás esetén sem lenne felszabadítható, mert belső pointer.

Javított sorrend, azonos előfeltételekkel:

/* ISO C17; részlet, <stdio.h> és <stdlib.h> szükséges. */
int *cursor = base + 1;
printf("%d\n", *cursor);

cursor = NULL;
free(base);
base = NULL;

Ellenőrzési feltétel: minden elemhozzáférés a felszabadítás előtt történik, és pontosan egy free kapja meg a foglalás kezdőpointerét.

8.1.8 Összefoglaló

A biztonságos dinamikus tömbkezelés sorrendje: méretszerződés, túlcsordulás-ellenőrzés, foglalás, hibaellenőrzés, inicializálás, majd egyértelmű tulajdonoshoz rendelés. A sizeof *p összeköti a méretszámítást a cél típusával; a calloc nullázása nem általános logikai inicializálás.

A free null pointerrel megengedett, egyébként élő foglalás kezdőpointerét igényli. A tároló felszabadítása minden hozzá tartozó kölcsönzött hozzáférést érvénytelenít, függetlenül attól, hány pointerváltozó őrzi még a korábbi címet.