5.1 Scope, linkage, storage duration és lifetime

Egy változó neve eltűnhet a látókörünkből úgy, hogy a hozzá tartozó objektum tovább él. Fordítva is előfordulhat: egy pointerváltozó még elérhető, miközben az objektum, amelyre korábban mutatott, már nem létezik. Az ilyen helyzeteket nem lehet pontosan leírni pusztán azzal, hogy valami „lokális”, „globális”, „stacken van” vagy „heapen van”.

Négy külön kérdést kell feltennünk:

A Szimbólumok, linkage és relocation rész linkeroldali nézőpontját most ISO C17 nyelvi szabályokkal egészítjük ki. A deklarációk olvasásához a Deklarációk, typedef és típusminősítők rész jelöléseit használjuk.

5.1.1 Hatókör: hol jelent valamit a név?

A hatókör a forrásprogramhoz tartozó fogalom, nem futás közbeni időintervallum. Ha egy belső deklaráció elfed egy külsőt, attól a külső objektum nem szűnik meg.

Blokkhatókör (block scope) tartozik például a függvénytörzsben vagy egy belső blokkban deklarált közönséges lokális azonosítóhoz. A függvénydefiníció paraméterének neve szintén blokkhatókörű, és a függvénytörzs végéig használható. Egy közönséges objektumazonosító hatóköre a deklarátora befejezése után kezdődik, tehát nem visszamenőleg, a blokk elején.

Fájlhatókör (file scope) tartozik a blokkokon és paraméterlistákon kívüli deklarációk azonosítóihoz. Ez a deklaráció helyétől a fordítási egység végéig tart. Nem jelenti azt, hogy a név automatikusan használható egy másik fordítási egységben: ott is megfelelő deklaráció kell.

Függvényhatókör (function scope) C-ben kizárólag a címkékhez tartozik. Egy goto célcímkéje az egész függvényben megnevezhető, akár a címke szöveges előfordulása előtt is. A függvény neve ettől még nem függvényhatókörű!

Függvényprototípus-hatókör (function prototype scope) vonatkozik a nem definícióként szereplő függvénydeklaráció paraméterneveire. Például az int parse(int count); deklarációban a count neve az adott függvénydeklarátor végéig rendelkezik hatókörrel. Ez a deklaráció nem hoz létre futás közbeni paraméterobjektumot.

Az alábbi ISO C17-részletben két külön objektum szerepel:

/* Függvénytörzs-részlet. */
int limit = 10;
{
    int limit = 3;  /* Elfedi a külső nevet. */
    limit += 1;
}
/* Itt ismét a külső limit neve használható; értéke 10. */

A külső objektum a belső blokk végrehajtása alatt is él. Csak a nevével történő közvetlen elérését akadályozza az elfedés.

5.1.2 Kapcsolás: mely deklarációk tartoznak össze?

A kapcsolás az azonosítókhoz kapcsolódik; nem azt mondja meg, hogy egy objektum meddig él.

Külső kapcsolás (external linkage) esetén a megfelelő deklarációk különböző fordítási egységekben is ugyanazt az objektumot vagy függvényt jelölhetik. Ehhez a deklarációknak, típusoknak és definícióknak a C szabályai szerint összhangban kell lenniük.

Belső kapcsolás (internal linkage) esetén az összetartozó deklarációk ugyanazon fordítási egységen belül jelölnek közös entitást. Tipikus eset a fájlhatókörben static kulcsszóval deklarált objektum vagy függvény.

Kapcsolás nélkül (no linkage) szerepelnek például a közönséges automatikus lokális változók, a paraméterek és a blokkban deklarált static objektumok nevei. Azonos írásmódjuk nem kapcsolja össze őket más blokkok hasonló nevű deklarációival.

Az extern nem egyszerűen azt jelenti, hogy „külső kapcsolás”. Ha látható egy korábbi, belső vagy külső kapcsolású deklaráció, az extern ezt a kapcsolást örökölheti:

/* ISO C17, fordításiegység-részlet. */
static int state;

void reset_state(void)
{
    extern int state;  /* A korábbi, belső kapcsolású objektum. */
    state = 0;
}

Itt nincs második state objektum. A blokkhatókörű deklaráció ugyanazt nevezi meg, mint a fájlhatókörű deklaráció. Korábbi látható deklaráció nélkül egy blokkhatókörű extern int state; külső kapcsolást adna.

5.1.3 A négy fogalom összehasonlítása

A következő táblázat ISO C17 szerinti, egymástól független deklarációs helyzeteket foglal össze. A tárolási idő és az élettartam az objektum tulajdonsága, nem a névé. A malloc(n) sorban pozitív méretű, sikeres allokációt feltételezünk.

Deklaráció vagy entitás A név hatóköre Kapcsolás Az objektum tárolási ideje Az objektum élettartama
Fájlhatókörű int total; Fájl Külső Statikus A program teljes végrehajtása
Fájlhatókörű static int hidden; Fájl Belső Statikus A program teljes végrehajtása
Blokkban int local; Blokk Nincs Automatikus A blokk adott végrehajtásába való belépéstől annak befejezéséig
Függvénydefiníció int arg paramétere Blokk Nincs Automatikus Az adott függvényhíváshoz kötött
Blokkban static int count; Blokk Nincs Statikus A program teljes végrehajtása
Blokkban extern int total;, a fenti total látható deklarációjával Blokk Külső A megnevezett objektumé: statikus A program teljes végrehajtása
Fájlhatókörű _Thread_local int tls; Fájl Külső Szálhoz kötött Szálanként külön objektum, az adott szál teljes végrehajtása
Blokkban static _Thread_local int cache; Blokk Nincs Szálhoz kötött Szálanként külön objektum, az adott szál teljes végrehajtása
Blokkban int *p = malloc(n);: maga a p Blokk Nincs Automatikus A blokk adott végrehajtásához kötött
Az előző p által megcímzett allokált objektum Nincs deklarált neve Név hiányában nem alkalmazható Allokált Az allokációtól a deallokációig
int parse(int count); prototípusban count Prototípus Nincs Nem keletkezik objektum Nem alkalmazható
Függvényen belüli done: címke Függvény Nincs Nem objektum Nem alkalmazható

A táblázat két fontos ellenpéldát tartalmaz. A blokkhatókörű static neve csak helyben használható, de objektuma az egész végrehajtás alatt él. A malloc eredményét tároló automatikus pointerváltozó megszűnhet anélkül, hogy az allokált objektum megszűnne.

5.1.3.1 A tárolási idők pontos határai

Automatikus tárolási időnél a nem változó hosszúságú objektum élettartama a hozzá tartozó blokkba való belépéstől a blokk végrehajtásának bármilyen módon történő befejezéséig tart. Másik függvény meghívása csak felfüggeszti ezt a végrehajtást: nem fejezi be az élettartamot. Rekurzív belépéskor új objektumpéldány keletkezik.

A változó hosszúságú tömb, vagyis VLA kivétel: élettartama a deklarációjától a hatókörének elhagyásáig tart. Részleteit a Többdimenziós tömbök, VLA és layout rész tárgyalja.

Statikus tárolási időnél az objektum a program teljes végrehajtása alatt él. Inicializálása a programindulás előtt megtörténik. C-ben a blokkhatókörű static inicializálása nem az első függvényhíváskor végrehajtott, tetszőleges kód.

Szálhoz kötött tárolási időnél minden szálhoz külön objektum tartozik. Blokkhatókörben a _Thread_local mellé static vagy extern szükséges. Másik szálból az objektum közvetett elérésének eredménye ISO C17 szerint implementáció által meghatározott; a szálak közötti pointerátadásra ezért itt nem építünk hordozható szerződést.

Allokált tárolási időnél a létrehozás és megszüntetés nem a blokkhatárt követi. A részletes könyvtári szerződés a malloc, calloc, free és allokációs szerződés témája. A sikeres realloc a régi objektum élettartamát lezárja akkor is, ha az új tároló címe ugyanolyannak látszik; ezt a realloc és dinamikusan növekvő buffer rész bontja ki.

Ezek nyelvi kategóriák, nem kötelező stack-, heap- vagy ELF-szekció-hozzárendelések. A gyakori megvalósítást a C objektumok a folyamat virtuális címterében rész külön kezeli.

5.1.4 Mikor használható még a pointer?

Legyen egy objektum élettartama a végrehajtás eseményeivel jelölt

Lx=[bx,ex), L_x=[b_x,e_x),

ahol bxb_x a kezdet, exe_x a megszűnés. Egy tt időpontban végzett objektumhozzáférés szükséges feltétele:

bx≤t<ex. b_x \le t < e_x.

Ez nem elégséges feltétel: a megfelelő objektumhatár, típus, igazítás és művelettől függően az inicializált érték is szükséges. Ezeket a következő részek pontosítják.

Bizonyításvázlat. A pointer másolása csak pointerértéket másol; nem módosítja a célobjektum tárolási idejét. Ha a célobjektum automatikus élettartama a függvényből visszatéréskor véget ér, egy kimásolt cím nem hosszabbíthatja meg azt.

Az élettartamon kívüli objektumhozzáférés nem definiált viselkedés, azaz UB. ISO C17-ben az objektumra vagy közvetlenül mögé mutató pointer értéke az objektum élettartamának végén meghatározatlanná (indeterminate) válik. A régi címet ezért ne hasonlítgassuk vagy naplózzuk érvényességvizsgálatként.

5.1.4.1 Hibapélda: lokális objektum címének visszaadása

/* ISO C17, szándékosan hibás részlet. */
static int *bad_value(void)
{
    int value = 42;
    return &value;
}

/* A *bad_value() kiértékelése UB: a célobjektum már nem él. */

A value tárolójának életciklusát a függvényhívás kezeli; a visszaadott pointer csak kölcsönzött hozzáférés lenne. Érvénytelenítő eseménye a visszatérés. Nincs meghatározott hibakimenet: sem a 42, sem összeomlás nem garantált.

A tárolóban esetleg megmaradó bitek nem jelentenek tovább élő objektumot. A NULL-ellenőrzés sem igazolja az élettartamot. Skalár eredménynél a természetes javítás az érték szerinti visszaadás.

5.1.5 Blokkon belüli static: tartós, de közös állapot

Az alábbi önálló példa minimum ISO C17-et igényel. Választott laborprofil: GCC 14.2.0, Linux x86-64 System V ABI, glibc 2.40. A program nem támaszkodik konkrét címméretre vagy objektumelrendezésre.

/* lifetime.c */
#include <assert.h>
#include <stdio.h>

static unsigned fresh_value(void)
{
    unsigned value = 0;
    return ++value;
}

static unsigned *shared_value(void)
{
    static unsigned value = 0;
    value = (value + 1u) % 10u;
    return &value;
}

int main(void)
{
    unsigned first = fresh_value();
    unsigned second = fresh_value();

    unsigned *borrowed = shared_value();
    unsigned snapshot = *borrowed;
    unsigned *again = shared_value();

    assert(first == 1u && second == 1u);
    assert(borrowed == again);
    assert(snapshot == 1u && *borrowed == 2u);

    printf("%u %u %u %u\n",
           first, second, snapshot, *borrowed);
    return 0;
}

Fordítás a külön tanulói laborban:

gcc -std=c17 -Wall -Wextra -Wpedantic -O0 -g lifetime.c -o lifetime

A szabályokból levezetett várt kimenet 1 1 1 2; ez nem mért futtatási eredmény.

A fresh_value minden hívása új automatikus objektumot kap. A shared_value összes hívása ugyanazt a statikus objektumot módosítja. A snapshot önálló értékmásolat, míg a két pointer ugyanahhoz az objektumhoz ad hozzáférést.

A statikus tárolót a C futtatási környezet kezeli; logikai tulajdonosa a függvényt biztosító modul. A pointerek nem tulajdonosok, ezért nem szabad rajtuk free-t hívni. Célobjektumuk a program végéig él. Újabb hívás nem érvényteleníti a pointert, de módosítja a megfigyelt értéket. A példa szerződése soros használat; nincs allokációs hibája, az assertionök szerződéssértést jelezhetnek.

A reentrancia (reentrancy) azt jelenti, hogy egy művelet korábbi meghívásának befejezése előtt újra meghívható a szerződése megsértése nélkül. Egy statikus munkapuffer vagy részszámítási állapot ezt könnyen megakadályozza: a belső hívás felülírhatja a külső hívás adatait. A hosszú élettartam tehát nem jelent hívásonként külön állapotot.

A módosítható statikus objektum párhuzamos elérése szinkronizáció nélkül adatversenyt is okozhat. A static önmagában nem szinkronizáció, a _Thread_local pedig nem oldja meg az ugyanazon szálon történő rekurzív vagy callbackes újrabelépést.

5.1.6 Gyakori hibák

A „lokális tehát rövid életű” következtetést a blokkhatókörű static cáfolja. A „static tehát belső kapcsolású” következtetés csak megfelelő deklarációs környezetben igaz: blokkban a névnek nincs kapcsolása.

Az automatikus pointerváltozó megszűnése nem szabadítja fel az általa megcímzett allokációt. Fordítva, a célobjektum megszűnése nem szünteti meg az összes rá mutató pointerváltozót. Ezek megmaradhatnak érvénytelen értékkel.

Végül a lokális cím visszaadásának static-kal történő „javítása” szerződésváltozás: a hívásonként külön eredményből közös, később felülírható állapot lesz.

5.1.7 Feladatok

5.1.7.1 Fogalmi besorolás

Fájlhatókörben szerepel a static int state;, egy függvény blokkjában pedig az extern int state;. Hány objektum van? Milyen a második név hatóköre, kapcsolása, illetve a megnevezett objektum tárolási ideje?

5.1.7.2 Érték szerinti javítás

Írd át a bad_value függvényt úgy, hogy statikus objektum és allokáció nélkül adjon vissza 42-t. Indokold, miért nem baj a lokális objektum megszűnése!

5.1.7.3 Élettartam-diagnózis

Vizsgáld meg ezt az ISO C17 szerinti, szándékosan hibás függvénytörzs-részletet:

int *saved = 0;
{
    int object = 9;
    saved = &object;
}
int result = *saved;

Ki kezeli az object tárolóját? Mikor érvénytelenedik a kölcsönzött hozzáférés? Javítana-e a helyzeten, ha csak a saved deklarációjához adnánk static kulcsszót?

5.1.8 Megoldások

5.1.8.1 Fogalmi besorolás megoldása

Egy objektum van. A második név blokkhatókörű, de a látható korábbi deklarációból belső kapcsolást örököl. Az objektum statikus tárolási idejű, élettartama a program teljes végrehajtása.

Ellenőrzési feltétel: a válasz külön kezelje a második deklaráció szűkebb hatókörét és a változatlan objektumélettartamot.

5.1.8.2 Érték szerinti javítás megoldása

/* ISO C17, függvénydefiníció-részlet. */
static int good_value(void)
{
    int value = 42;
    return value;
}

A visszatérési kifejezés kiértékelésekor a value még él. A hívó az egész értéket kapja meg, nem az objektum címét. Ellenőrzési feltétel: az eredmény további használata ne függjön a függvény lokális tárolójától.

5.1.8.3 Élettartam-diagnózis megoldása

Az object automatikus tárolójának életciklusát a belső blokk végrehajtása kezeli. A saved csak kölcsönzött hozzáférést tárol. A belső blokk elhagyása lezárja a célobjektum élettartamát; az ezt követő dereferencia UB, garantált eredmény nélkül.

A saved statikussá tétele csak a pointerobjektum életét hosszabbítaná meg. A célobjektumét nem. Helyes javítás például a szükséges érték kimásolása még a belső blokkban. Ellenőrzési feltétel: minden célobjektum-hozzáférés annak élettartamán belül történjen.

5.1.9 Összefoglaló

A hatókör a név használhatóságát, a kapcsolás a deklarációk összetartozását írja le. A tárolási idő az objektum tárolásának időbeli kategóriája, az élettartam pedig az objektum létezésének konkrét intervalluma. Egy pointer fennmaradása nem hosszabbítja meg a célobjektum életét; egy hosszú életű objektum pedig még nem biztosít reentráns vagy szálbiztos interfészt.