Aby naprawdę zrozumieć, dlaczego wskaźniki zachowują się w ten sposób, musisz przestać myśleć abstrakcyjnymi zmiennymi i zacząć wizualizować sprzęt. To prosty model, ale stanowi podstawę wszystkiego, od błędów seg (awarie programów z powodu naruszeń dostępu do pamięci) po luki w zabezpieczeniach. Jeśli nie masz pewności co do podstaw pracy z bitami, bajtami i słowami, prawdopodobnie powinieneś zatrzymać się w tym miejscu i odświeżyć tę wiedzę. Poniższe wyjaśnienie zakłada, że znasz różnicę między bajtem a bitem.
Twój komputer jest wyposażony w pamięć lub RAM (pamięć o dostępie swobodnym). Można mieć zainstalowaną 16, 32 lub 64 gigabajty takiej pamięci. To nie tylko przechowywanie; to jest miejsce do pracy. Przechowuje kod aktualnie wykonywany przez procesor (CPU) oraz dane, z którymi w tej chwili pracują te programy. Pomyśl o pamięci RAM jako o ogromnej liniowej tablicy bajtów.
Każdy bajt w tej tablicy ma swój własny adres. Pierwszy bajt ma adres 0. Następny to 1. Następnie 2, 3 i tak dalej. Adresy te działają dokładnie tak, jak indeksy w standardowej tablicy programistycznej. Ponieważ procesor może natychmiast przeskoczyć pod dowolny adres, pamięć nazywa się „pamięcią o dostępie swobodnym”. Nie ma potrzeby sekwencyjnego odczytywania danych. W każdej chwili może uzyskać potrzebne dane. Kiedy kompilator musi przechowywać większy typ danych, po prostu przydziela blok sąsiadujących bajtów. Na przykład standardowa liczba zmiennoprzecinkowa (float) zwykle zajmuje 4 kolejne bajty.
Spójrzmy na globalną deklarację w C:
Dla Ciebie jest to zmienna o nazwie „f”, która przechowuje wartość zmiennoprzecinkową. W przypadku komputera jest to polecenie zarezerwowania 4 określonych bajtów w tablicy pamięci. Załóżmy, że kompilator przypisuje te bajty do lokalizacji pamięci 248 440.
Kiedy piszesz:
Kompilator nie myśli o „aktualizowaniu zmiennej f”. Myśli „załaduj wartość 3,14 do adresu pamięci 248440”. Abstrakcja jest bardzo subtelna. Pod maską wszystko sprowadza się do adresów i wartości znalezionych pod tymi adresami.
Ta mechaniczna natura pamięci prowadzi do nieprzyjemnych skutków ubocznych. Rozważmy ten fragment kodu C:
Możesz spodziewać się, że s i t będą zawierać 0:0, 1:1, 2:2, 3:3. A u powinno pozostać 0. Zamiast tego wynik wygląda następująco:
Poczekaj. Co się stało z t[0]? I dlaczego „u” jest teraz równe 5?
Problemem jest klasyczne przepełnienie bufora. Przyjrzyj się bliżej warunkowi pętli: i<=4. Tablica t jest zadeklarowana jako t[4], co oznacza, że ma indeksy 0, 1, 2 i 3. Kiedy i osiągnie 4, kod zapisuje dane do t[4]. Indeks ten nie istnieje w przestrzeni przydzielonej dla t. Zapis następuje jeden element poza tablicą.
Ponieważ pamięć jest ciągła, komputer umieszcza s, t i u tuż obok siebie na stercie lub stosie.
Kiedy zapisujesz dane poza tablicą, program nie ulega natychmiastowej awarii. Nadpisujesz zawartość pamięci znajdującą się obok tablicy.
W tym konkretnym przypadku zapis do t[4] nadpisuje lokalizację pamięci, w której przechowywane jest u (pierwotnie było to 0). Ale dlaczego „ty”.
Napisz do s[4]. Ten indeks nie istnieje. Ale komputer się tym nie przejmuje. Nie sprawdza, czy stąpasz na własnych nogach. Po prostu oblicza adres. I ten adres okazuje się być adresem t[0].
Myślisz, że zapisujesz dane do „s”. Właściwie piszesz je w t. System wykonuje polecenie bez mrugnięcia okiem. Logika jest zepsuta. Program uszkadza dane. Wszystko jest ciche, dopóki nie zapadnie cisza.
Teraz spróbuj czegoś gorszego.
s[1000000] = 5;
Zapisujesz do pamięci, której twój program nie posiada. Konsekwencje zależą całkowicie od używanego systemu operacyjnego. W bezpiecznych systemach, takich jak UNIX, Windows NT lub Windows 98, system operacyjny natychmiast wykrywa to naruszenie. Kończy program. Twardy. Chroni resztę systemu przed Twoim błędem.
Starsze systemy, takie jak Windows 3.1 czy klasyczny Mac OS, nie mają tego luksusu. Nie wiedzą, co robisz. W rezultacie nadpiszesz kod lub zmienne innej aplikacji. Wynik? Awaria. Rozbić się. Lub, co gorsza, awaria systemu, która wydaje się przypadkowa, ale w rzeczywistości jest bezpośrednim skutkiem niekontrolowanego dostępu do pamięci.
W pamięci zmienne i, s, t i u znajdują się obok siebie, pod określonymi adresami. Są sąsiadami. Jeśli wyjdziesz poza granice jednej zmiennej, będziesz mieć wpływ na następną. Komputer robi dokładnie to, co mu każesz. Po prostu nie obchodzi go, że działałeś z dobrymi intencjami.
Niebezpieczeństwo niekontrolowanego dostępu do tablic
C i C++ nie wykonują sprawdzania zakresu. Zakładają, że wiesz, co robisz. Oznacza to, że musisz uważnie obserwować zakresy tablic. Jeśli odczytasz lub zapiszesz dane poza granicami, program będzie zachowywał się błędnie. Niepewny. Niebezpieczny.
Ten brak bezpieczeństwa jest powodem, dla którego niezainicjowane błędy wskaźników są tak powszechne w C. Rozważmy ten kod:
Kompilator wyprowadza wartość „p” i adres „i”. Początkowo p zawiera śmieci lub zero. Adres „i” jest zwykle dużą liczbą. Na przykład możesz zobaczyć:
Po p = &i, p zawiera adres i. Tylko.
Teraz spójrz na to:
Wyświetla wartość wskazywaną przez „p”. Ale p nie jest inicjowane. Wskazuje adres 0 lub jakąś losową lokalizację w pamięci. Rezultatem jest prawie zawsze błąd segmentacji. Błąd wykonania. Próbujesz uzyskać dostęp do pamięci, która nie należy do Ciebie.
Drogowskazy pod nowym kątem
Gdy zrozumiesz te ryzyko, zmieni się Twoje podejście do wskaźników. To nie tylko zmienne. Są to bezpośrednie adresy komórek pamięci.
Weźmy ten program:
Oto, co się tutaj dzieje:
Jak wskaźniki przechowują adresy pamięci
Kiedy deklarujesz zmienną typu całkowitego, taką jak i, zajmuje ona 4 bajty pamięci o dostępie swobodnym (RAM). Wskaźnik, nazwijmy go p, również zajmuje 4 bajty. Rozmiar ten dotyczy zdecydowanej większości nowoczesnych systemów, w których adresy pamięci mają długość 32 bitów. Branża powoli zmierza w stronę adresowania 64-bitowego, ale na razie standardem pozostaje rozmiar 4-bajtowy.
Wyobraź sobie, że i to dom. Ma konkretny adres, powiedzmy 248440. Znak p nie oznacza samego domu. To jest kartka papieru z zapisanym adresem. Kiedy przypisujesz p = &i, piszesz liczbę 248,440 na tej kartce papieru.
To rozróżnienie ma kluczowe znaczenie dla zrozumienia jak działają wskaźniki w C. Wskaźnik nie przechowuje wartości i. Przechowuje lokalizację, w której znajduje się i. Jeśli usuniesz odwołanie do p (używając p *), uzyskasz dostęp do tego adresu i odczytasz wartość. W tym sensie p i i * są funkcjonalnie identyczne. Wskazują na te same dane.
Wyświetlanie adresu w kodzie
Możesz przetestować to zachowanie za pomocą prostej instrukcji wyjściowej.
printf("%d", p);
To polecenie nie drukuje wartości i. Pokazuje numer zapisany na kartce papieru. Wynikiem jest rzeczywisty adres pamięci zmiennej i.
Dlaczego to jest ważne? Ponieważ umożliwia przesyłanie adresów, a nie kopii danych. Można bezpośrednio manipulować pamięcią. Można łączyć ze sobą struktury. Możesz efektywnie zarządzać pamięcią na stercie. Wskaźnik jest łączem. Zmienną jest treść. Zamieszanie między nimi prowadzi do błędów. Zrozumienie różnic daje Ci kontrolę.
Niektórzy programiści traktują wskaźniki jako coś magicznego. To jest błędne. To tylko liczby. Adresy pamięci są liczbami całkowitymi. Traktuj je odpowiednio.


















