Zrozumienie wskaźników do wskaźników w C

0
7

Brzmi to jak koszmar rekurencji, ale wskaźniki do wskaźników są integralną częścią programowania systemów. Zasadniczo wskazujesz adres pamięci, który zawiera adres twoich rzeczywistych danych. To podwójne adresowanie pośrednie nazywane jest przez programistów uchwytem. To nie jest tylko sztuczka w kodzie. Jest to niezbędny mechanizm używany przez systemy operacyjne do wydajnego zarządzania pamięcią sterty.

Potraktuj stertę jako zatłoczony pokój. Czasami system operacyjny musi przenieść ludzi, aby zwolnić miejsce. Jeśli trzymasz bezpośredni wskaźnik do kogoś, ta osoba nie może się poruszyć bez zerwania łącza. Jeśli jednak przytrzymasz wskaźnik do listy nazwisk, system operacyjny może zmienić lokalizację dowolnej osoby na tej liście bez konieczności aktualizowania początkowego odniesienia. To jest siła pióra.

Oto jak ta logika przekłada się na surowy kod C. Deklarujesz p jako wskaźnik do wskaźnika. Następnie „q” staje się standardowym wskaźnikiem. Przydzielasz pamięć dla „p”, a następnie przydzielasz pamięć dla tego, na co wskazuje „p”. Na koniec dwukrotnie usuwasz referencję do wskaźnika, aby przypisać mu wartość 12.

Systemy Windows i macOS wykorzystują tę strukturę do kompresji pamięci. To rozróżnienie jest tutaj istotne. Ty, jako programista, kontrolujesz zewnętrzny wskaźnik p. System operacyjny zarządza wewnętrznym wskaźnikiem *p. Ponieważ system operacyjny kontroluje *p, może przenieść rzeczywisty blok danych (**p) w dowolne miejsce na stercie. Po prostu aktualizuje *p, aby odzwierciedlić nowy adres. Twój kod nadal bez problemów używa p.

Oprócz zarządzania pamięcią systemu operacyjnego ten wzorzec jest potrzebny do przekazywania wskaźników do funkcji. Jeśli potrzebujesz funkcji do modyfikacji samego wskaźnika, przekazujesz wskaźnik do tego wskaźnika. Tylko w ten sposób można ponownie przypisać łącze z innego zakresu.

Zarządzanie wskaźnikami do struktur

Złożoność nie ogranicza się do prostych liczb całkowitych. Możesz umieścić tę logikę wewnątrz struktur. Jest to powszechne podczas przetwarzania danych o zmiennej długości, takich jak ciągi znaków.

Rozważmy strukturę Addr. Zawiera tablice o stałym rozmiarze z nazwami, miastami i numerami telefonów. Ale komentarze mają różną długość. Dlatego „komentarz” definiuje się jako wskaźnik do znaku (char). Alokując pamięć dla samej struktury, rezerwujesz miejsce na wskaźniki i stałe tablice. Nie zarezerwowałeś jeszcze miejsca na właściwy tekst komentarza.

Najpierw zaznacz „s”. Następnie czytasz dane wejściowe użytkownika do stałych buforów. Następnie tymczasowo przeczytasz komentarz

Zignorujesz zagnieżdżanie wskaźników, marnujesz pamięć.

Rozważmy wskaźnik „s”, który wskazuje na strukturę. Struktura ta zawiera kolejny wskaźnik. Ten drugi wskaźnik wskazuje rzeczywisty ciąg w pamięci. Dwa poziomy adresowania pośredniego. Jedna alokacja pamięci na strukturę. Jedna alokacja pamięci na ciąg.

Wszystko jest proste, dopóki nie spróbujesz zwolnić pamięci.

W tym miejscu potyka się większość programistów. Widzisz „bezpłatne” (bezpłatne). Myślisz, że wszystko jest gotowe. Mylisz się.

Spójrz na ten fragment kodu:

Zmienna s wskazuje na strukturę Addr. Wewnątrz tej struktury znajduje się pole „komentarz”. „komentarz” wskazuje blok pamięci na stercie przydzielony na dane wiersza.

Kiedy wywołujesz free(s), zwalniasz pamięć zajmowaną przez samą strukturę. Struktura Addr znika. Pamięć jest zwracana do systemu.

Ale co się dzieje z s->komentarzem?

Znika w zapomnieniu.

Wskaźnik do danych ciągu został zapisany w strukturze, którą właśnie zwolniłeś. Dostęp do niego nie jest już możliwy. Nie wywołałeś funkcji free() w łańcuchu. Pamięć pozostaje przydzielona, ​​ale jest niedostępna.

To jest wyciek pamięci.

To nie jest awaria. To nie jest komunikat o błędzie. Program działa dobrze. Po prostu powoli zużywa coraz więcej pamięci RAM, aż system zacznie korzystać z pliku strony lub ulegnie awarii.

Jak naprawić wycieki spowodowane podwójnymi wskaźnikami

Najpierw musisz zwolnić wewnętrzny wskaźnik. Lub zapisz go w zmiennej tymczasowej przed zwolnieniem struktury zewnętrznej.

Teraz te linie są zwolnione. Następnie konstrukcja jest uwalniana. Nie ma utraconych bloków pamięci.

Dlaczego zdarza się to tak często?

Ludzie postrzegają wskaźniki jako wartości. Zapominają, że wskaźniki są łączami do zasobów.

Kiedy struktura zawiera wskaźnik, nie jest ona samodzielna. To zależy od pamięci zewnętrznej. Zwolnienie kontenera nie powoduje zwolnienia jego zawartości.

Sytuacja pogarsza się w przypadku głębszego zagnieżdżenia. Wskaźnik do wskaźnika do wskaźnika. Trzy poziomy. Potrzebujesz trzech wywołań funkcji free(). We właściwej kolejności.

Jeśli o którymś zapomnisz, nastąpi wyciek.

Problem z funkcją gets()

W przykładzie zastosowano funkcję gets(). Ta funkcja jest niebezpieczna. Został usunięty ze standardu C11, ponieważ umożliwia przepełnienie bufora. Ale logika pracy z pamięcią pozostaje ta sama.

Niezależnie od tego, czy używasz gets(), fgets() czy scanf(), strategia alokacji pamięci stanowi wąskie gardło prowadzące do wycieków.

Przydzielasz pamięć dla s.
Alokujesz pamięć dla s->komentarz.

Jeśli zwolnisz tylko s, zostawisz s->komentarz.

Kluczowe wnioski

  • Podwójne wskaźniki wymagają podwójnego zwolnienia.
  • Sprawdź każdy malloc pod kątem odpowiedniego free.
  • Jeśli struktura zawiera wskaźnik do pamięci dynamicznej, pamięć ta musi zostać zwolniona, zanim będzie można zwolnić samą strukturę.
  • free() nie zwalnia rekurencyjnie elementów struktury.

Łatwo to przegapić. Kod się kompiluje. To działa. Wyciek następuje niezauważony.

Dopóki nie jest za późno.

Twórz powiązane listy

Zwolnienie kontenera przed danymi, na które wskazuje, może spowodować awarię pamięci. Struktura zawierająca wskaźnik zostanie wyczyszczona, ale blok łańcucha pozostanie. Stanie się zagubionym blokiem. Dzieje się tak, gdy kolejność wydawania jest nieprawidłowa.

Łączenie

Struktury mogą wskazywać na siebie. Umożliwia to łączenie identycznych rekordów w łańcuch. Rezultatem jest lista połączona. Jest to standardowy sposób organizowania danych w języku C.

Oto jak to zdefiniowano:

Pole „next” zawiera adres kolejnego rekordu. Do uruchomienia łańcucha używana jest pojedyncza zmienna wskaźnikowa.

Koszt elastyczności

Kompilator pozwala łamać zasady, które na pierwszy rzut oka mogą wydawać się sprzeczne z intuicją. Mając wystarczające doświadczenie, możesz tworzyć konstrukcje podobne do pokazanych powyżej. To pokaz siły.

Ale nie jest to pozbawione ryzyka. Kroczysz cienką linią między inteligentnym kodem a niemożliwym do utrzymania kodem spaghetti.

Dlaczego to jest ważne?

Nie chodzi tylko o składnię. Chodzi o to, co pozwala ci osiągnąć język, gdy pokonasz jego ograniczenia.

  • Kontrola : Otrzymujesz szczegółową kontrolę nad lokalizacją danych w pamięci.
  • Interoperacyjność : Możesz łatwiej wchodzić w interakcję z bibliotekami C.
  • Wydajność : Czasami ominięcie kontroli bezpieczeństwa oszczędza cykle procesora.

Ale jest niuans: kompilator nie uratuje cię przed tobą. Umożliwi to skompilowanie kodu, który ulega awarii w czasie wykonywania.

Jak bezpiecznie go używać

Jeśli już to robisz, rób to świadomie.

  1. Izoluj : Nie rozpowszechniaj tego wzorca w całym kodzie. Trzymaj go w małym, dobrze przetestowanym module.
  2. Udokumentuj wszystko : W przyszłości podziękujesz teraźniejszości. Albo będzie tego nienawidził. Najprawdopodobniej będzie tego nienawidzić.
  3. Używaj abstrakcji : Zawijaj surowe wskaźniki lub niebezpieczne bloki w przejrzysty interfejs. Ukryj bałagan.

Kontrola rzeczywistości

Większość programistów tego nie potrzebuje. Będą używać standardowych struktur. I będą dzięki temu szczęśliwsi.

Ale kiedy natkniesz się na ścianę, w której nie mieści się standardowa biblioteka, będziesz zadowolony, że kompilator Cię nie powstrzymał.

Pytanie brzmi, czy jesteś skłonny zapłacić cenę za obsługę takiego kodu.

To kompromis. Prędkość w zamian za bezpieczeństwo. Moc dla przejrzystości.

Wybór należy do Ciebie.

попередня статтяUnix vs Linux: Dlaczego ta różnica jest ważna dla programistów
наступна статтяJak syntezatory zmieniły muzykę, łamiąc zasady formy