Jak działają wskaźniki do struktur w C

10

W C możesz wskazać prawie wszystko. Język umożliwia tworzenie wskaźników dla typów pierwotnych, tablic, funkcji i typów zdefiniowanych przez użytkownika. Ale wskaźniki do struktur są tam, gdzie robi się interesująco. Są niezwykle powszechne. Jeśli piszesz kod w C, prawdopodobnie będziesz się z nimi regularnie spotykać.

Oto podstawowy przykład. Pokazuje, jak zdefiniować strukturę, a następnie utworzyć do niej wskaźnik.

Ten fragment kodu robi więcej, niż tylko przypisuje adres pamięci. Ustanawia połączenie pomiędzy zmienną „ptr” a strukturą „p1”. Wskaźnik przechowuje adres struktury. Umożliwia to pośredni dostęp do elementów x i y. To jest podstawowy wzór. Widać to na połączonych listach, dynamicznej alokacji pamięci i argumentach funkcji.

Po co używać wskaźników do struktur?

Kopiowanie całych struktur jest kosztowne. Jeśli Twoja struktura zawiera dużo danych, przekazanie ich przez wartość do funkcji oznacza, że ​​kompilator wykona pełną kopię. To marnuje czas i pamięć. Używanie wskaźnika pozwala uniknąć kopiowania. Podajesz adres zamiast wartości. Funkcja może modyfikować dane źródłowe bez narzutu.

Istnieje również kwestia dynamicznego wymiarowania. Tablice mają stały rozmiar w czasie kompilacji. Struktury same w sobie niekoniecznie rozwiązują problem zwinności. Ale kiedy połączysz strukturę z malloc, otrzymasz elastyczne przechowywanie danych. Możesz przydzielić strukturę na stercie. Wskaźnik do tej struktury umożliwia ręczne zarządzanie jej cyklem życia.

Dostęp do członków za pomocą wskaźników

Gdy już masz wskaźnik, musisz uzyskać dostęp do danych w nim zawartych. Nie można używać operatora kropki. Wyrażenie ptr.x nie zostanie skompilowane. ptr to adres, a nie sama struktura. Najpierw musisz to wykasować.

Można to zrobić na dwa sposoby. Pierwszy z nich jest szczegółowy.

Nawiasy są tutaj wymagane. Operator kropki ma wyższy priorytet niż operator dereferencji. Bez nich kompilator będzie próbował odwołać się do ptr.x, który nie istnieje.

Druga metoda jest czystsza. C udostępnia operator strzałki ->. Łączy w sobie dereferencje i dostęp do elementów członkowskich w jednym kroku.

Ta składnia jest standardowa. To właśnie zobaczysz w prawdziwych bazach kodu. Brzmi to naturalnie jako „idź do obiektu wskazywanego przez ptr i pobierz jego element x ”. Jest zwięzły. Redukuje szumy wizualne.

Typowe błędy

Wskaźniki NULL są ryzykowne. Jeśli zadeklarujesz wskaźnik, ale go nie zainicjujesz, będzie on wskazywał śmieci. Dostęp do ptr->x dla wskaźnika zerowego powoduje błąd segfault. Przed użyciem wskaźnika należy sprawdzić wartość NULL.

To sprawdzenie nie jest opcjonalne w zaufanym kodzie. Zapobiega awariom.

Kolejnym problemem są wycieki pamięci. Jeśli przydzielisz pamięć za pomocą malloc i utracisz wskaźnik, pamięć ta pozostanie zarezerwowana do końca programu. Musisz śledzić każdą alokację. free() to Twoja odpowiedzialność. Zapomnij o tym

Wskaźniki strukturalne w C często stanowią przeszkodę dla programistów, którzy dobrze radzą sobie z podstawowymi wskaźnikami, ale są zdezorientowani składnią. Przyjrzyjmy się prostej definicji wpisu dla profilu użytkownika.

Tutaj „RecPointer” jest po prostu aliasem wskaźnika do struktury „Rec”. Deklarując zmienną tego typu, masz do czynienia z adresem w pamięci, a nie z samymi danymi.

Zmienna r zajmuje cztery bajty w systemie 32-bitowym (lub osiem w systemie 64-bitowym). To tylko wskaźnik. Nie zawiera nazwy, miasta ani stanu. Aby przechowywać prawdziwe dane, musisz przydzielić pamięć na stercie.

To wywołanie malloc rezerwuje 45 bajtów. Jest to 21 bajtów na nazwę, 21 na miasto i 3 na stan, plus jeden bajt na uzupełnienie lub wyrównanie w celu dopasowania do granic pamięci. Teraz „r” wskazuje na prawidłowy blok pamięci, który zachowuje się dokładnie tak, jak struktura „Rec”.

Dostęp do członków poprzez dereferencje

Aby móc wchodzić w interakcję z danymi, należy wyłuskać wskaźnik. Tutaj często pojawiają się błędy związane z priorytetami działania. Dostęp do elementów członkowskich można uzyskać przy użyciu standardowej notacji kropkowej, ale operację wyłuskiwania należy ująć w nawiasy.

Zwróć uwagę na składnię. (*r).name jest poprawne. Jeśli napiszesz *r.name, kod nie zostanie skompilowany. Dlaczego? Ponieważ operator kropki ma wyższy priorytet niż operator dereferencji. Kompilator interpretuje *r.name jako *(r.name). Ponieważ r jest wskaźnikiem, r.name nie ma poprawnej składni i wyrażenie nie powiedzie się. Nawiasy wymuszają wyłuskanie najpierw *r, uzyskując strukturę, a następnie operator kropki uzyskuje dostęp do pola nazwa.

Pisanie tego jest nudne. Wygląda na zagraconą. To powoduje błędy.

Notacja strzałkowa

C zapewnia czystszy sposób pracy z tym. Operator strzałki -> jest cukrem składniowym dla (*wskaźnika).członka. To nie jest inny mechanizm. Nie jest to nowy operator zmieniający sposób dostępu do pamięci. To po prostu krótszy sposób na napisanie operacji dereferencji i uzyskania dostępu do elementu struktury w jednym kroku.

Jest to identyczne z strcpy((*r).name, "Leigh"). Oszczędza to dwa znaki. Eliminuje to potrzebę stosowania zagnieżdżonych nawiasów. Jest to standardowy sposób interakcji większości programistów C ze wskaźnikami struktur.

Konsekwencje dla zarządzania pamięcią

Wymagane jest wywołanie free(r). Pamięć została przydzielona ze sterty. Jeśli go nie zwolnisz, nastąpi wyciek pamięci. Sam wskaźnik „r”, czterobajtowa zmienna przechowująca adres, jest lokalny w stosunku do ramki stosu lub ma zasięg globalny, ale dane, na które wskazuje, znajdują się gdzie indziej.

Używając r->name, zmieniasz dane pod adresem zapisanym w r. Sam wskaźnik „r” pozostaje niezmieniony. Adres nie

Przydzielanie pamięci tablicom na bieżąco jest integralną częścią programowania w C, ale wymaga zrozumienia, w jaki sposób wskaźniki oddziałują z surowymi blokami pamięci. Jeśli potrzebujesz tablicy o stałym rozmiarze, której rozmiar jest nieznany w czasie kompilacji, standardowa alokacja stosu nie wystarczy. Będziemy musieli uzyskać dostęp do sterty.

Poniższy fragment kodu ilustruje jeden typowy wzorzec:

Tutaj malloc rezerwuje miejsce na dziesięć liczb całkowitych. Rzutowanie na (int *) zapewnia, że ​​wskaźnik pasuje do oczekiwanego typu, chociaż współczesne kompilatory C często wyświetlają ostrzeżenia o jawnym rzutowaniu dla void*. Następnie pętla inicjuje każdy element na zero, stosując notację z indeksem dolnym. Na koniec „free” zwraca pamięć do systemu.

Ale to nie jedyny sposób na nagrywanie.

Możesz uzyskać dokładnie ten sam wynik, zastępując p[i] arytmetyką wskaźników:

Dlaczego to jest ważne? Ponieważ p[i] jest po prostu cukrem syntaktycznym dla *(p+i). Kompilator przetwarza je identycznie. Jeśli pracujesz z systemami wbudowanymi lub piszesz gęste pętle, w których liczy się każdy znacznik, świadomość, że te konstrukcje są wymienne, pomoże Ci odczytać starszy kod i napisać własny bez zamieszania.

Jednak kryje się tu subtelna pułapka.

Jeśli bezpośrednio zadeklarujesz wskaźnik do typu tablicy – na przykład int (*p)[10] – masz do czynienia z zupełnie innym mechanizmem. Wskaźnik ten wskazuje na całą tablicę jako całość, a nie tylko na jej pierwszy element. Zwiększanie takiego wskaźnika przesuwa go o rozmiar całej tablicy, a nie o rozmiar pojedynczej liczby całkowitej. Większość programistów używa int *, ponieważ jest to prostsze i bardziej elastyczne.

Kiedy zastosować które podejście

Wybór między notacją indeksową a jawną arytmetyką wskaźników często sprowadza się do czytelności i intencji.

  • Użyj p[i], jeśli chcesz podkreślić dostęp oparty na indeksie. Dla większości czytelników jest to bardziej oczywiste.
  • Użyj *(p+i), gdy manipulujesz pamięcią na niskim poziomie lub chcesz uniknąć funkcji „rozpadu tablicy” w złożonych wyrażeniach.

Obydwa podejścia wymagają ostrożnego zarządzania pamięcią. Zapomniałem nazwać „bezpłatny” – powoduje to wyciek pamięci. Zadzwoń do tego zbyt wcześnie, a otrzymasz niezdefiniowane zachowanie. I chociaż „malloc” wygląda tutaj prosto, przed wyłuskaniem wskaźnika zawsze sprawdź, czy zwracana wartość nie jest równa „NULL”.

“Wskaźniki tablic są potężne, ale wymagają dyscypliny. Jeden zły ruch i czytasz śmieci lub, co gorsza, uszkadzasz pamięć innej zmiennej. “

W praktyce większość programistów rzadko musi pisać arytmetykę surowych wskaźników dla prostych tablic. Biblioteki takie jak std::vector w C++ lub abstrakcje wyższego poziomu w innych językach radzą sobie z tym automatycznie. Ale w C? Z tym zadaniem zostajesz sam.

Dlatego tak ważne jest zrozumienie mechaniki. Nie tylko do zaliczania rozmów kwalifikacyjnych czy pisania podręczników, ale także wtedy, gdy o 2 w nocy kod się psuje i trzeba dokładnie wiedzieć, co dzieje się w pamięci.

Deklarując wskaźnik do tablicy liczb całkowitych, nie tworzysz niczego egzotycznego. To po prostu standardowy wskaźnik do int. Magia dzieje się z malloc. Przydzielasz blok pamięci wystarczająco duży, aby pomieścić potrzebną liczbę liczb całkowitych. Wskaźnik wskazuje następnie pierwszy element tego bloku.

C nie obchodzi, w jaki sposób uzyskasz do niego dostęp. Można używać nawiasów kwadratowych, np. „p[5]”, lub arytmetyki wskaźników, np. „*(p + 5)”. Kompilator uważa je za identyczne. Ta elastyczność sprawia, że ​​tablice dynamiczne są tak przydatne w przypadku ciągów. Nie zgadniesz rozmiaru. Przydzielasz dokładnie tyle pamięci, ile jest wymagane dla długości ciągu znaków plus terminator zerowy.

Tablice wskaźników a tablice struktur

Po co używać tablicy wskaźników, skoro można po prostu użyć tablicy struktur? Miejsce. A dokładniej, jego brak.

Rozważmy strukturę „Rec” z trzema tablicami znaków po 81 bajtów każda. To 243 bajty na zapis. Jeśli zadeklarujesz Rekordy nagrywania[10], natychmiast zarezerwujesz 2430 bajtów w pamięci. Nagle. Nawet jeśli kiedykolwiek użyjesz tylko jednego wpisu.

Tablica wskaźników zmienia matematykę.

Sama tablica a zawiera tylko 10 wskaźników. W systemie 64-bitowym jest to 80 bajtów. To tylko niewielka część pamięci wymaganej dla kompletnych struktur. Pamięć na same nagrania pozostaje niewykorzystana, dopóki jej nie potrzebujesz.

W razie potrzeby możesz zaznaczyć jeden wpis.

Ten wzorzec rozwiązuje problemy wymagające dużej ilości pamięci poprzez odroczenie alokacji. Płacisz tylko za to, z czego korzystasz. Kiedy skończysz pisać, mówisz „za darmo”. Wskaźnik zwisa, ale pamięć wraca do systemu.

Struktury zawierające wskaźniki

Struktury mogą zawierać wskaźniki. Umożliwia to mieszanie danych o stałym rozmiarze z danymi o zmiennym rozmiarze w jednym obiekcie.

Weźmy wpis w książce adresowej. Imię i nazwisko, miasto i numer telefonu mogą mieć rozsądną maksymalną długość. Jednakże komentarz może obejmować dowolne słowo lub całą powieść. Nie chcesz marnować miejsca, poświęcając ogromny bufor każdemu wpisowi, na wypadek, gdyby ktoś napisał długi komentarz.

Sama struktura Addr jest niewielka

Jak pola komentarzy obsługują puste i wypełnione wpisy

Nie każdy wpis w bazie danych zawiera komentarz. Kiedy pole pozostaje puste, nie jest ono po prostu puste. Zawiera wskaźnik. Mianowicie 4-bajtowy wskaźnik, który na nic nie wskazuje. System uznaje tę nieobecność za warunek ważny. Rekord pozostaje kompletny. Metadane zostały zapisane.

Ale co się stanie, gdy użytkownik faktycznie wprowadzi tekst?

Zmienia się proces alokacji pamięci. Baza danych nie rezerwuje stałego bufora dla tych komentarzy. Nie przyjmuje maksymalnej długości i nie wypełnia pozostałej przestrzeni zerami. Byłoby to marnotrawstwem. Zamiast tego system oblicza dokładną długość ciągu. Następnie przydziela dokładnie tyle bajtów, ile potrzeba.

Ta dynamiczna alokacja jest skuteczna. Zapobiega fragmentacji spowodowanej nadmiarem danych. Krótka notatka zajmuje kilka bajtów. Długi esej to coś więcej. Wskaźnik w wejściu o wartości null wskazuje odwołanie o wartości null, oszczędzając minimalną ilość miejsca. Wpisy treści są rozciągane, aby pomieścić dane. Nic się nie marnuje. Nic nie jest wymuszone.

Czy to jedyny sposób przechowywania tekstu? Nie. Ale to mądry sposób na zrównoważenie prędkości i przestrzeni. Otrzymujesz szybkość reakcji nagłówków o stałym rozmiarze z elastycznością danych o zmiennej długości. To jest mały szczegół. Mechanizm niskopoziomowy. Ma to jednak znaczenie, gdy zarządzasz milionami rekordów. Baza danych „oddycha” łatwiej. Użycie dysku pozostaje niewielkie.

A co z użytkownikiem? Nigdy nie widzi wskaźnika. Widzi tylko swój komentarz. Lub jego brak. Złożoność jest ukryta. Zoptymalizowane przechowywanie. Rezultatem jest system, który wydaje się lekki nawet w miarę wzrostu ilości danych.