Jak fungují ukazatele na struktury v C

11

V C můžete ukázat téměř na cokoli. Jazyk umožňuje vytvářet ukazatele na primitivní typy, pole, funkce a uživatelem definované typy. Ale ukazatele na struktury jsou místa, kde věci začínají být zajímavé. Jsou extrémně časté. Pokud píšete kód C, pravděpodobně se s nimi budete setkávat pravidelně.

Zde je základní příklad. Ukazuje, jak definovat strukturu a poté na ni vytvořit ukazatel.

Tento kus kódu umí více než jen přiřadit adresu paměti. Vytváří spojení mezi proměnnou ptr a strukturou p1. Ukazatel ukládá adresu struktury. To vám umožňuje nepřímo přistupovat ke členům x a y. Toto je základní vzorec. Vidíte to v propojených seznamech, dynamické alokaci paměti a argumentech funkcí.

Proč používat ukazatele na struktury?

Kopírování celých struktur je drahé. Pokud vaše struktura obsahuje velké množství dat, předání hodnoty do funkce znamená, že kompilátor vytvoří úplnou kopii. To plýtvá časem a pamětí. Použití ukazatele zabraňuje kopírování. Místo hodnoty předáváte adresu. Funkce může upravovat zdrojová data bez režie.

Je zde také otázka dynamického dimenzování. Pole mají v době kompilace pevnou velikost. Struktury samy o sobě nemusí nutně řešit problém agility. Ale když zkombinujete strukturu s malloc, získáte flexibilní úložiště dat. Na haldě můžete alokovat strukturu. Ukazatel na tuto strukturu vám umožňuje ručně spravovat její životní cyklus.

Přístup ke členům pomocí ukazatelů

Jakmile budete mít ukazatel, musíte získat přístup k datům uvnitř. Operátor tečky nelze použít. Výraz ptr.x se nezkompiluje. ptr je adresa, nikoli struktura samotná. Nejprve to musíte dereferencovat.

Toho lze dosáhnout dvěma způsoby. První je podrobný.

Závorky jsou zde povinné. Operátor tečky má vyšší prioritu než operátor dereference. Bez nich se kompilátor pokusí odkázat na ptr.x, který neexistuje.

Druhý způsob je čistší. C poskytuje operátor šipky ->. Spojuje dereferencování a přístup členů do jednoho kroku.

Tato syntaxe je standardní. To je to, co uvidíte ve skutečných kódových základech. Čte se přirozeně jako “přejděte na objekt, na který ukazuje ptr a získejte jeho člen x “. Je stručný. Snižuje vizuální šum.

Časté chyby

Ukazatele NULL jsou riskantní. Pokud deklarujete ukazatel, ale neinicializujete jej, ukazuje na smetí. Přístup ptr->x pro nulový ukazatel způsobí chybu segfault. Před použitím musíte zkontrolovat ukazatel na hodnotu NULL.

Tato kontrola není v důvěryhodném kódu volitelná. Zabraňuje pádům.

Dalším problémem jsou úniky paměti. Pokud alokujete paměť pomocí malloc a ztratíte ukazatel, zůstane tato paměť rezervovaná až do konce programu. Musíte sledovat každou alokaci. free() je vaší zodpovědností. Zapomeňte na to

Struct ukazatele v C jsou často kamenem úrazu pro vývojáře, kterým vyhovují základní ukazatele, ale jsou zmateni syntaxí. Podívejme se na jednoduchou definici záznamu pro uživatelský profil.

Zde je RecPointer jednoduše alias pro ukazatel na strukturu Rec. Když deklarujete proměnnou tohoto typu, jednáte s adresou v paměti, nikoli s daty samotnými.

Proměnná r zabírá čtyři bajty na 32bitovém systému (nebo osm na 64bitovém systému). Je to jen ukazatel. Neobsahuje jméno, město ani stát. Chcete-li uložit skutečná data, musíte alokovat paměť na haldě.

Toto volání malloc rezervuje 45 bajtů. To je 21 bajtů pro název, 21 pro město a 3 pro stát plus jeden bajt pro výplň nebo zarovnání, aby odpovídalo hranicím paměti. Nyní r ukazuje na platný blok paměti, který se chová přesně jako struktura Rec.

Přístup ke členům prostřednictvím dereferencování

Chcete-li pracovat s daty, musíte dereferencovat ukazatel. Zde se často vyskytují chyby související s prioritami operací. Ke členům můžete přistupovat pomocí standardní tečkové notace, ale operaci dereference musíte zabalit do závorek.

Věnujte pozornost syntaxi. (*r).name je správné. Pokud napíšete *r.name, kód se nezkompiluje. Proč? Protože operátor tečky má vyšší prioritu než operátor dereference. Kompilátor interpretuje *r.name jako *(r.name). Protože r je ukazatel, r.name není platná syntaxe a výraz se nezdaří. Závorky vynutí nejprve dereferencování *r, čímž se získá struktura, a poté operátor tečka přistoupí k poli jméno.

Tohle je únavné psát. Vypadá přeplněně. To vyvolává chyby.

Šipkový zápis

C poskytuje čistší způsob, jak s tím pracovat. Operátor šipky -> je syntaktický cukr pro (*ukazatel).member. Nejedná se o jiný mechanismus. Nejedná se o nový operátor, který mění způsob přístupu k paměti. Je to jen kratší způsob, jak zapsat operaci dereference a přístup ke členu struktury v jednom kroku.

Toto je identické s strcpy((*r).name, "Leigh"). Tím se ušetří dvě postavy. Tím odpadá nutnost používat vnořené závorky. Toto je standardní způsob, jakým většina vývojářů v jazyce C interaguje s ukazateli struct.

Důsledky pro správu paměti

Je vyžadováno volání free(r). Paměť byla přidělena z haldy. Pokud jej neuvolníte, dojde k nevracení paměti. Samotný ukazatel r, čtyřbajtová proměnná ukládající adresu, je lokální v rámci zásobníku nebo má globální rozsah, ale data, na která ukazuje, jsou jinde.

Když použijete r->name, měníte data na adrese uložené v r. Samotný ukazatel r zůstává nezměněn. Adresa není

Přidělování paměti pro pole za běhu je nedílnou součástí programování v jazyce C, ale vyžaduje pochopení toho, jak ukazatele interagují s bloky surové paměti. Když potřebujete pole s pevnou velikostí, jehož velikost je v době kompilace neznámá, standardní přidělení zásobníku nestačí. Budeme se muset dostat k hromadě.

Níže uvedený fragment kódu ukazuje jeden společný vzor:

Zde malloc rezervuje místo pro deset celých čísel. Přetypování na (int *) zajišťuje, že ukazatel odpovídá očekávanému typu, ačkoli moderní kompilátory C často vydávají varování před explicitním přetypováním pro void*. Smyčka pak inicializuje každý prvek na nulu pomocí zápisu dolního indexu. Nakonec free vrátí paměť do systému.

Není to ale jediný způsob záznamu.

Přesně stejný výsledek můžete získat nahrazením p[i] aritmetikou ukazatele:

Proč je to důležité? Protože p[i] je jen syntaktický cukr pro *(p+i). Kompilátor je zpracovává identicky. Pokud pracujete s vestavěnými systémy nebo píšete husté smyčky, kde se počítá každý tick, vědomí, že tyto konstrukce jsou zaměnitelné, vám pomůže číst starší kód a psát svůj vlastní bez zmatků.

Je zde však rafinovaná past.

Pokud přímo deklarujete ukazatel na typ pole – například int (*p)[10] – máte co do činění s úplně jiným mechanismem. Tento ukazatel ukazuje na celé pole jako celek, nejen na jeho první prvek. Zvýšením takového ukazatele se posune o velikost celého pole, nikoli o velikost jednoho celého čísla. Většina vývojářů používá int *, protože je jednodušší a flexibilnější.

Kdy použít který přístup

Volba mezi indexovým zápisem a explicitní aritmetikou ukazatele často závisí na čitelnosti a záměru.

  • Použijte p[i], když chcete zdůraznit přístup založený na indexu. To je pro většinu čtenářů jasnější.
  • Použijte *(p+i), když provádíte nízkoúrovňovou manipulaci s pamětí nebo se potřebujete vyhnout funkcím “rozpadu pole” složitých výrazů.

Oba přístupy vyžadují pečlivou správu paměti. Zapomněli jste zavolat „zdarma“ – uniká vám paměť. Zavolejte to příliš brzy a dostanete nedefinované chování. A i když zde malloc vypadá jednoduše, před dereferencováním ukazatele vždy zkontrolujte, zda návratová hodnota není NULL.

“Ukazatele polí jsou výkonné, ale vyžadují disciplínu. Jeden špatný pohyb a čtete nesmysly, v horším případě poškozujete paměť jiné proměnné.”

V praxi většina vývojářů zřídka potřebuje psát nezpracovanou aritmetiku ukazatelů pro jednoduchá pole. Knihovny jako std::vector v C++ nebo abstrakce vyšší úrovně v jiných jazycích to zvládají automaticky. Ale v C? Na tento úkol jste zůstali sami.

To je důvod, proč je důležité porozumět mechanice. Nejen pro absolvování pohovorů nebo psaní učebnic, ale také když kód praskne ve 2 hodiny ráno a vy potřebujete přesně vědět, co se děje v paměti.

Když deklarujete ukazatel na pole celých čísel, nevytváříte nic exotického. Je to jen standardní ukazatel na int. Kouzlo se děje s malloc. Alokujete blok paměti dostatečně velký pro počet celých čísel, která potřebujete. Ukazatel pak ukazuje na první prvek tohoto bloku.

C nezajímá, jak k němu přistupujete. Můžete použít hranaté závorky, například p[5], nebo aritmetiku ukazatele, například *(p + 5). Kompilátor je považuje za totožné. Tato flexibilita je to, co dělá dynamická pole tak užitečná pro řetězce. Velikost neuhodnete. Alokujete přesně tolik paměti, kolik je požadováno pro délku řetězce plus null terminátor.

Pole ukazatelů vs. pole struktur

Proč používat pole ukazatelů, když můžete použít pouze pole struktur? Místo. Nebo přesněji jeho nedostatek.

Zvažte strukturu „Rec“ se třemi poli znaků po 81 bajtech. To je 243 bajtů na zápis. Pokud deklarujete Rec records[10], okamžitě si zarezervujete 2430 bajtů v paměti. Najednou. I když použijete jen jeden záznam.

Pole ukazatelů mění matematiku.

Samotné pole a obsahuje pouze 10 ukazatelů. Na 64bitovém systému je to 80 bajtů. Toto je pouze malá část paměti potřebné pro kompletní struktury. Paměť pro samotné nahrávky zůstane nevyužitá, dokud ji nebudete potřebovat.

Podle potřeby můžete zvýraznit jeden záznam.

Tento vzor řeší problémy náročné na paměť odložením alokace. Platíte pouze za to, co používáte. Až skončíte s psaním, zavoláte „zadarmo“. Ukazatel se zavěsí, ale paměť se vrátí do systému.

Struktury obsahující ukazatele

Struktury mohou obsahovat ukazatele. To vám umožní kombinovat data pevné velikosti s daty proměnné velikosti v jediném objektu.

Vezměme si záznam v adresáři. Jméno, město a telefonní číslo mohou mít přiměřenou maximální délku. Komentář však může být cokoli od jednoho slova po celý román. Nechcete plýtvat místem vyčleněním obrovské vyrovnávací paměti pro každý záznam pro případ, že někdo napíše dlouhý komentář.

Samotná struktura Addr je malá

Jak pole komentářů zpracovávají prázdné a vyplněné položky

Ne každý záznam v databázi obsahuje komentář. Když je pole prázdné, není jen prázdné. Obsahuje ukazatel. Totiž 4bajtový ukazatel, který na nic neukazuje. Systém považuje tuto absenci za platnou podmínku. Záznam zůstává kompletní. Metadata uložena.

Co se ale stane, když uživatel skutečně zadá text?

Proces přidělování paměti se mění. Databáze pro tyto komentáře nerezervuje pevnou vyrovnávací paměť. Nepředpokládá maximální délku a nevyplňuje zbývající prostor nulami. Bylo by to plýtvání. Místo toho systém vypočítá přesnou délku řetězce. Poté alokuje přesně tolik bajtů, kolik je potřeba.

Tato dynamická alokace je účinná. Zabraňuje fragmentaci kvůli nadýmání dat. Krátká poznámka zabere několik bajtů. Dlouhý esej je víc. Ukazatel v nulové položce ukazuje na nulovou referenci, což šetří minimální místo. Položky obsahu jsou roztaženy, aby vyhovovaly datům. Nic není nazmar. Nic se nenutí.

Je to jediný způsob, jak uložit text? Ne. Ale je to chytrý způsob, jak vyvážit rychlost a prostor. Získáte odezvu hlaviček pevné velikosti s flexibilitou dat s proměnnou délkou. To je malý detail. Mechanismus nízké úrovně. Ale záleží na tom, když spravujete miliony záznamů. Databáze snadněji „dýchá“. Využití disku zůstává kompaktní.

A co uživatel? Nikdy nevidí ukazatel. Vidí jen jeho komentář. Nebo jeho nedostatek. Složitost je skryta. Optimalizováno úložiště. Výsledkem je systém, který je lehký, i když data rostou.