Egy láncolt listában a pointerek helyes átállítása csak a feladat egyik fele. A másik: ki tartja életben a csomópontot, ki szabadíthatja fel, és mit jelent egy korábban eltett bejárópozíció a lista módosítása után? Egy feladatütemezőben például ugyanaz a munkaobjektum átkerülhet a várakozó listából a futó listába anélkül, hogy maga az objektum elköltözne.
A fejezet a struktúrák értékmásolására, az explicit tulajdonlási szerződésre és a pointeraritmetika objektumhatáraira épít. A példák ISO C17-et használnak; a listákhoz egy időben egy végrehajtási szál fér hozzá.
Az egyszeresen láncolt lista (singly linked list) csomópontja a következő csomópontra mutat. A kétszeresen láncolt lista (doubly linked list) az előzőre is tárol hivatkozást. Ezek a kapcsolatok önmagukban semmit sem mondanak a tulajdonlásról.
A tulajdonló lista (owning list) felel a csomópontjai felszabadításáért. Gyakori megvalósításában minden beszúrás külön csomópontot allokál. A csomópontban tárolt payload tulajdonlása ettől még külön döntés: egy benne tárolt pointer lehet kölcsönzött is.
Az intrusive lista kapcsolómezői magába a nyilvántartott objektumba vannak beágyazva. Emiatt nincs szükség külön listacsomópont-allokációra. Az intrusive jelleg azonban nem tulajdonlási szabály: itt kifejezetten nem tulajdonló intrusive listát tervezünk.
| Szempont | Tulajdonló listamodell | Az itt használt intrusive modell |
|---|---|---|
| Csomópont tulajdonosa | A lista | A hívó |
| Kapcsolómezők helye | Külön csomópontban is lehetnek | A munkaobjektumban |
| Eltávolítás | Leválasztás és rendszerint felszabadítás | Csak leválasztás |
| Áthelyezés másik listába | Tulajdonosváltás is | Csak tagságváltás |
| Payload felszabadítása | Külön szerződés szerint | A külső tulajdonos feladata |
Ugyanaz az objektum több intrusive listában is szerepelhet, de minden
egyidejű tagsághoz külön kapcsolópár szükséges. Ugyanazt a
next/prev párt két lista nem használhatja.
A sentinel, vagy őrszemcsomópont, szerkezeti elem,
nem felhasználói adat. Körkörös, kétszeresen láncolt listánál az őrszem
next mezője az első, prev mezője az utolsó
elemre mutat. Üres listában mindkettő önmagára mutat.
Minden bekapcsolt csomópontra, az őrszemet is beleértve, megköveteljük:
További invariáns, hogy a next kapcsolatokat követve
minden adatelem pontosan egyszer érinthető az őrszemhez való visszatérés
előtt. A lokális egyenletek önmagukban ezt nem bizonyítják: egy
különálló kör is teljesítheti őket.
Egyszeresen láncolt listában egy ismert előd után beszúrni vagy törölni konstans számú kapcsolatmódosítás. Pusztán a törlendő csomópont ismerete általában nem elég: az előd megkeresése lineáris lehet. Kétszeresen láncolt listánál az előd közvetlenül rendelkezésre áll.
Az őrszemet tartalmazó listaobjektumnak stabil címen kell maradnia. A
struct értékmásolása nem készít új, önálló listát: az
átmásolt pointerek továbbra is az eredeti objektumokra mutatnak.
A következő teljes programban a main a job
objektumok tulajdonosa. A listák csak kölcsönzik őket. A
member_of mező nyilvántartja a tagságot, így felismerhető a
kétszeres beszúrás és a rossz listából történő eltávolítás.
Előfeltételek: minden átadott pointer élő,
megfelelően inicializált objektumra mutat; a listák invariánsai
teljesülnek; a bekapcsolt objektumokat és listaobjektumokat nem másolják
vagy költöztetik. A false visszatérés felismert
szerződéssértést jelent, változatlan állapottal. Érvénytelen pointert
ezek az ellenőrzések nem tesznek biztonságossá.
A list.c tartalma:
#include <stdbool.h>
#include <stddef.h>
#include <stdlib.h>
struct list;
struct job {
struct job *prev;
struct job *next;
struct list *member_of;
unsigned id;
};
struct list {
struct job root;
};
static void list_init(struct list *l)
{
l->root = (struct job){
.prev = &l->root,
.next = &l->root,
.member_of = l
};
}
static bool list_push_back(struct list *l, struct job *n)
{
if (n->member_of != NULL ||
n->next != NULL || n->prev != NULL)
return false;
struct job *tail = l->root.prev;
n->prev = tail;
n->next = &l->root;
tail->next = n;
l->root.prev = n;
n->member_of = l;
return true;
}
static bool list_unlink(struct list *l, struct job *n)
{
if (n == &l->root || n->member_of != l)
return false;
n->prev->next = n->next;
n->next->prev = n->prev;
n->prev = NULL;
n->next = NULL;
n->member_of = NULL;
return true;
}
static bool list_move_back(struct list *dst,
struct list *src,
struct job *n)
{
if (dst == src || n == &src->root ||
n->member_of != src)
return false;
if (!list_unlink(src, n))
return false;
/* Az invariáns szerint n most leválasztott. */
return list_push_back(dst, n);
}
int main(void)
{
struct list pending;
struct list running;
struct job a = {.id = 7};
struct job b = {.id = 8};
list_init(&pending);
list_init(&running);
if (!list_push_back(&pending, &a) ||
!list_push_back(&pending, &b))
return EXIT_FAILURE;
struct job *cursor = &a;
if (!list_move_back(&running, &pending, &a))
return EXIT_FAILURE;
if (cursor->member_of != &running)
return EXIT_FAILURE;
if (!list_unlink(&pending, &b) ||
!list_unlink(&running, cursor))
return EXIT_FAILURE;
return EXIT_SUCCESS;
}Fordítási profil: ISO C17, hosted környezet; javasolt laborprofil Linux x86-64, System V ABI, LP64, GCC 14.2.0 vagy Clang 19.1.0, glibc 2.39. A kód nem használ ABI-függő layoutot vagy fordítóbővítést. Az alábbi parancsokhoz nem tartozik itt igazolt fordítási vagy futási eredmény.
gcc -std=c17 -Wall -Wextra -Wpedantic -Wconversion -O1 -g list.c -o list-gcc
clang -std=c17 -Wall -Wextra -Wpedantic -Wconversion -O1 -g list.c -o list-clangBeszúráskor először az új csomópont két kapcsolatát állítjuk be, majd a két szomszédot kapcsoljuk hozzá. A művelet közben átmenetileg nem teljesül minden invariáns; ezért ez nem konkurens hozzáférésre alkalmas algoritmus.
Bizonyításvázlat: az üres lista teljesíti az
invariánst. Beszúrás csak a régi utolsó elem és az őrszem közötti
kapcsolatot bontja ketté. Leválasztás az eltávolított elem két
szomszédját közvetlenül összeköti. Minden más kapcsolat változatlan,
ezért a teljes lista invariánsa megmarad. A list_move_back
ellenőrzései után a leválasztás sikeres, majd a beszúrás feltételei
szükségképpen teljesülnek: nincs allokációs vagy más várható közbülső
hibakimenet.
A cursor itt eltett csomópont-pointer és az ahhoz társított bejárási szerződés. Külön kell választani az objektum létezését és a listabeli pozíció érvényességét.
| Művelet | A csomópont-pointer állapota | Listabeli jelentése |
|---|---|---|
| Másik elem beszúrása | Érvényes marad | Továbbra is ugyanaz az elem |
| Saját elem leválasztása | Az objektum él, pointere használható | Már nem listapozíció |
| Saját elem áthelyezése | Címe változatlan | A célhoz tartozik |
| Objektum felszabadítása vagy élettartamvége | Dangling | Nem használható tovább |
| Lista újrainicializálása nem üresen | Az elemek még élhetnek | A tagsági szerződés megsérül |
A példában cursor az áthelyezés után továbbra is
a-ra mutat, de már nem használható a pending
lista bejárójaként.
A splice meglévő csomópontok átkapcsolása másik listapozícióra, másolás és új csomópont-allokáció nélkül. A példában egyetlen elemet mozgatunk eltérő listák között. Tulajdonló listánál ehhez a felszabadítási szabályok kompatibilitása és a tulajdonjog átadása is szükséges.
Általános, tartományt mozgató splice esetén rögzíteni kell, hogy a tartomány összefüggő, a forráshoz tartozik, és a célpozíció nem esik a mozgatott tartomány belsejébe. Az üres tartományt és a saját listán belüli áthelyezést külön kell specifikálni.
Fontos költségkülönbség: a határkapcsolatok átállítása lehet
,
de a példabeli member_of mező frissítése
mozgatott elemnél
.
A diagnosztikai metaadat is része a költségmodellnek.
Az beszúrás feltétele, hogy a pozíció már ismert. Ha előbb azonosító alapján keresünk, a teljes művelet lehet. Tulajdonló listánál az allokáció költsége sem következik a kapcsolatmódosítás konstans műveletszámából.
Bejáráskor a következő cím az aktuális csomópontból olvasható ki. Ez a pointer chasing adatfüggő hozzáférési lánc: a következő olvasás gyakran csak az előző eredménye után indítható. Szétszórt csomópontoknál gyenge lehet a cache-locality, és több lapot kell érinteni.
A folytonos tömb szintén időben járható be, mégis kedvezőbb lehet az előbetöltés és a cache-kihasználás. Az intrusive lista elhagyja a külön csomópont-allokációt, de nem garantál jó localityt: azt az objektumok tényleges elhelyezése határozza meg. Itt nem mért gyorsulásról, hanem eltérő költségtényezőkről beszélünk.
Generikus intrusive megoldások gyakran külön kapcsolóstruktúrát
ágyaznak a payloadba. A container_of jellegű művelet a
kapcsolómező pointeréből próbálja visszakapni a befoglaló objektum
pointerét.
A gyakori recept a mező címéből kivonja az
offsetof(T, member) értéket. Az offsetof ISO
C17-es eszköz, de a numerikusan helyes offset önmagában nem igazolja a
teljes visszaalakítás hordozhatóságát. A karakterpointeres aritmetika
objektumhatárait és a kapott pointer jogosultságát is vizsgálni
kell.
Egyes makrók GNU typeof vagy statement expression
bővítést használnak; ezek nem ISO C17-szolgáltatások. Más változat
szintaktikailag szabványosnak látszhat, miközben továbbra is
implementációs feltételezésekre épít.
További előfeltétel a tényleges befoglaló típus, a megfelelő tag, valamint az élő befoglaló objektum. Önálló sentinel-hookra alkalmazva a makró nem varázsol mögé payloadot.
ISO C17: a struktúra és első, nem bitmező tagja
közötti megfelelő pointerkonverzió külön nyelvi garanciát kap; ez nem
általános felhatalmazás tetszőleges belső tagból történő visszalépésre.
A példánk teljes, típusos job őrszemet használ, ezért nincs
szüksége container_of műveletre.
A kétszeres beszúrás felülírhatja a már használt kapcsolatokat, elveszett elemeket vagy hibás ciklust létrehozva. A tagságjelző ezt a szabályos API-használat keretein belül felismeri.
A körkörösség önmagában nem hiba: a sentinel-lista szándékosan körkörös. Az a hiba, ha a bejárás az őrszem elérése nélkül ismétel csomópontot.
A felszabadítás előtti leválasztás elmulasztása dangling
next/prev kapcsolatot hagy a
listában. A törlési ciklus másik gyakori hibája, hogy a következő
pointert csak a leválasztás vagy felszabadítás után olvassa ki. A
következő pozíciót előre kell elmenteni, és annak élettartamát is
garantálni kell.
Magyarázat: egy egyszeresen láncolt listában csak a törlendő csomópont pointerét ismered. Miért nem ígérhetsz általánosan törlést? Mi változik kétszeresen láncolt listánál?
Kódolás: írj függvényt, amely a példabeli
listából leválasztja a páros id értékű munkákat. Az
objektumokat nem szabad felszabadítani.
Diagnosztika: mi romlik el, ha egy bekapcsolt munkát újra beszúrnak, egy nem üres listát érték szerint lemásolnak, vagy egy heapen tárolt munkát leválasztás nélkül felszabadítanak? Adj ellenőrzési feltételt mindháromhoz.
Egyszeresen láncolt listánál az előd next mezőjét kell
módosítani, ezért az előd megkeresése
lehet. Kétszeresen láncolt listánál az ismert, érvényes csomópont
prev mezője biztosítja ezt az információt. Az ellenőrzési
feltétel: a törölt csomópont kihagyásával minden megmaradó elem
elérhető, eredeti sorrendben.
Az alábbi C17-részlet a list.c függvényeire épül. A
tulajdonos továbbra is a hívó; minden csomópont él a teljes bejárás
alatt. A leválasztás csak az aktuális listapozíciót érvényteleníti.
static bool unlink_even(struct list *l)
{
struct job *p = l->root.next;
while (p != &l->root) {
struct job *next = p->next;
if (p->id % 2u == 0u && !list_unlink(l, p))
return false;
p = next;
}
return true;
}Ellenőrzésként próbálj üres, csak páros, csak páratlan és váltakozó
listát. A megmaradó elemek sorrendje változatlan; minden leválasztott
elem három kapcsolati mezője nullpointer. A false belső
szerződéssértést jelez, nem tranzakciós visszagörgetést.
A kétszeres beszúrást a member_of != NULL feltétel
utasítja el; ellenőrizni kell, hogy a lista változatlan maradt.
Az értékmásolat kapcsolatai az eredeti őrszemre és csomópontokra mutatnak. Javítása nem pointerfoltozás: új listát kell inicializálni, majd meghatározott másolási vagy áthelyezési szerződést alkalmazni.
Leválasztás nélküli felszabadítás után a szomszédok érvénytelen élettartamra hivatkoznak. A tulajdonosnak előbb sikeresen le kell választania az elemet, majd felszabadítania; utána egyetlen listakapcsolat és használható cursor sem hivatkozhat rá.
A lista kapcsolati topológiája, tulajdonlása és cursorérvényessége három külön szerződés. Az intrusive tárolás stabil című objektumokat és explicit tagságkezelést igényel. A splice megőrizheti a csomópont címét, miközben megváltoztatja a tagságát vagy tulajdonosát. A konstans számú pointermódosítás pedig nem jelent automatikusan olcsó keresést, allokációt vagy cache-barát bejárást.