W tym materiale zapoznam Cię z absolutnie kluczowym elementem języka C 😲. Element stanowiący kręgosłup samego pojęcia "referencji". Jest nim wskaźnik! Dowiesz się czym w języku C jest wskaźnik, jak go rozpoznać w kodzie, co się pod nim kryje oraz jakie nam oferuje możliwości! Do dzieła 💥!
WSKAŹNIK W JĘZYKU C. NARZĘDZIE OGROMNIE POSZERZAJĄCE MOŻLIWOŚCI
Pojęcie "wskaźnik" i wszystkie zagadnienia związane z nimi, to temat rzeka na co najmniej kilka osobnych artykułów 🤯. Ten materiał traktuj jak wprowadzenie - najlżejszy fragment z całego rozdziału dotyczącego zarządzania pamięcią 🫵! Zaczniemy sobie od teorii samego terminu i ukazaniu prostego przykładu, tak abyśmy mogli delikatnie przesuwać się wgłąb tego obszernego tematu 👍.
DEFINICJA
Wskaźnik jest to typ danych przechowujący odniesienie do innej naszej danej. Odniesienie, czyli inaczej, referencja. Napisałem "danej", gdy to nie musi być zmienna. Wskaźnik może też wskazywać na funkcję i w ten sposób jest możliwe m.in. tworzenie wywołań zwrotnych (ang. callback) i funkcji wyższego rzędu, natomiast nie wchodźmy teraz w takie detale 🙃.
Wskaźnik w języku C jest na samym początku trudny do opanowania 🧠. Wymaga od człowieka "przestawienia się" na inny tok myślenia o uzyskiwaniu pożądanego efektu lub odpowiedniej struktury, zwłaszcza jeśli już programujesz w językach wyższego poziomu, takich jak Java czy C#, w których nie musisz się przejmować manualnym zarządzaniem pamięcią, bo to robi za Ciebie kompilator, a "gotowce" w postaci wyrażeń lambda i innych elementów wykorzystujących wskaźniki, są na wyciągnięcie ręki 🙂.
Język C jest od tej strony bezlitosny i jeżeli masz ochotę wykorzystać pełnię jego możliwości, to musisz bardzo dobrze operować wskaźnikami 🔥! Nie tylko znać znak asterysku i ampersandu, natomiast też doskonale wiedzieć czego użyć w danej sytuacji oraz w jaki sposób skonstruować wyrażenie z wplecionym wskaźnikiem, aby uzyskać strukturą działającą identycznie, jak coś gotowego w językach o zwiększonej abstrakcji (np. wyrażenie lambda, o którym wspomniałem) 💣!
PRAKTYCZNE ZASTOSOWANIA WSKAŹNIKÓW
Wskaźniki dają mnóstwo możliwości, których nie osiągnie się bez ich udziału. Tak naprawdę dopiero one odkrywają cały potencjał języka C 👑. Oto co w dużym skrócie jest możliwe dzięki wskaźnikom 👇:
- pobieranie wartości przez operator "wyłuskania" (dereferencji) ze swobodnym "przestawianiem" danego adresu pamięci ➡️ "wydobywanie" wartości przez wskaźnik, który "wskazuje" na daną komórkę pamięci,
- przekazywanie wartości typów danych do funkcji przez referencję ➡️ utrwalanie modyfikacji wartości zmiennych typu prostego po zakończeniu funkcji, bez konieczności zwracania wartości,
- definiowanie wyrażeń lambda ➡️ "wplatanie" funkcji jako parametru innej funkcji umożliwiające stosowanie "wymiennych instrukcji" co każde wywołanie (element programowania funkcyjnego),
- definiowanie funkcji wyższego rzędu ➡️ operowanie na funkcjach przyjmujących wyrażenie lambda jako parametr bądź zwracające wskaźnik do innej funkcji (również element programowania funkcyjnego),
- zarządzanie dynamicznymi strukturami danych np. listami powiązanymi ➡️ stosowanie zbiorów danych z elastycznie dopasowującym się rozmiarem podczas działania programu.
Wszystko co wymieniłem, jest możliwe do osiągnięcia wyłącznie z użyciem wskaźników 🚀! Masz co najmniej 5 solidnych argumentów, żeby zainteresować się ich nauką 😄!
Zaznaczę (już na uboczu), że tablica, choć jest statyczną strukturą danych, także ma ścisły związek ze wskaźnikami - ona sama jest wskaźnikiem 😱! Łańcuchy znaków również opierają się o wskaźniki i wszystkie podstawowe funkcje operujące na nich 🔥!
PIERWSZY KONTAKT ZE WSKAŹNIKIEM
Przejdźmy teraz do zaznajamiania się ze wskaźnikiem od strony kodu. Tworzone są za pomocą znaku asterysku, czy jak wolisz, gwiazdki (*) umieszczanej pomiędzy typem danych, a nazwą 👇:
int* pointerToNumber;Wskaźnik przechowuje adres pamięci i to za jego pośrednictwem powstaje "relacja" z danym obiektem danych (czyt. zmienną bądź funkcją ℹ️). Aczkolwiek, nic nas nie broni przed utworzeniem wskaźnika bez przypisania adresu 🙄. Jak się domyślasz, nie wróży to niczego dobrego ❌.
Pod żadnym pozorem nie operuj na niezainicjowanym wskaźniku ⛔! Wskaźnik w języku C musi mieć poprawnie przypisany adres, aby bezpiecznie zarządzać pamięcią poprzez uzyskiwanie dostępu i modyfikację ⚠️! Zignorowanie tej informacji przyniesie Ci rezultat w postaci niezdefiniowanego zachowania 💥!
DEFINICJA ZMIENNEJ WSKAŹNIKOWEJ
Pierwsza rzecz jaką musisz przyswoić, to to, żeby od razu definiować wskaźnik! Tylko jak to zrobić? Dajmy na to, że mamy liczbę całkowitą 👇:
int number = 5;i chcemy dodać wskaźnik, aby wskazywał na tę liczbę celem późniejszej modyfikacji wartości przez ten wskaźnik. Wtedy możemy zapisać tak:
int* pointerToNumber = &number;W tym momencie poznajesz drugi operator związany z referencjami - ampersand (&)! On pobiera adres danej zmiennej 😲!
WYŚWIETLANIE ADRESU PAMIĘCI
Gdybyśmy chcieli wyświetlić uzyskany adres, możemy skorzystać z funkcji "printf" i skorzystać ze specyfikatora "%p" stworzonego do wypisywania adresów w systemie szesnastkowym (heksadecymalnym):
printf("%p\n", pointerToNumber);Nie zdziw się, jeśli ujrzysz inny adres za każdym razem, gdy uruchamiasz ten sam program 👀. To jest normalny proces zarządzania pamięcią przez system operacyjny 😊.
W języku C istnieją 2 wyjątki niewymagające ampersandu: tablice i łańcuchy znaków, gdyż są to dane same w sobie będące wskaźnikami. Wstawiając ich nazwę, już od razu uzyskujesz adres do pierwszego argumentu, z racji "bycia" typem referencyjnym ℹ️.
UZYSKIWANIE DOSTĘPU DO WARTOŚCI POPRZEZ OPERATOR "WYŁUSKANIA" (DEREFERENCJI)
Jeżeli potrzebujesz uzyskać dostęp do wartości "wskazywanej" przez wskaźnik, używasz tego samego znaku asterysku i dopisujesz go przed nazwą zmiennej:
printf("%d\n", *pointerToNumber);lecz uwaga - tutaj będzie zupełnie inne znaczenie tego operatora 🔴! Gdy wstawiasz asterysk w tym miejscu, to wykonujesz tak zwane "wyłuskanie" wartości (dereferencję), czyli "wydobywasz" wartość ze wskaźnika jaki w danej chwili obowiązuje ℹ️. Aby nie rozkładać wprowadzenia na wiele wątków naraz, dalsze szczegóły znajdziesz w odrębnym artykule.
Zobacz, że chcąc wyświetlić tę wartość przez "printf", wtedy korzystasz z innego specyfikatora zgodnego z typem danych - w tym przypadku "%d", bo wartością "wydobytą" będzie "int" 💡.
LEPIEJ POSTAWIĆ ZNACZEK Z LEWEJ CZY Z PRAWEJ STRONY?
Od razu wyjaśnijmy sobie taki niuansik. Możesz zauważyć, że ktoś pisze tak 👇:
int* pointerToNumber;a ktoś inny wstawia asterysk przed nazwą zmiennej:
int *pointerToNumber;Od razu piszę: obie formy są równoważne. To jest wyłącznie kwestia upodobania estetycznego, natomiast dla kompilatora nie ma znaczenia gdzie umieścisz ten znaczek 😄. Ja umieszczam go po typie danych, bo jednak jest to modyfikacja typu, a nie samej zmiennej, natomiast może mylić w momencie zadeklarowania wielu zmiennych w jednym wierszu:
int* pointerToNumber, *pointerToNumberB;Wtedy może być lepiej zostawienie asterysku "przyklejonego" do nazwy 😉.
PRZYKŁAD KODU ŹRÓDŁOWEGO
Na finał, prosty kod źródłowy, który prezentuje jak zdefiniować wskaźnik, prawidłowo przypisać adres i jak "wydobyć" wartość poprzez dereferencję 👇:
#include <stdio.h>
void printAddressToVariable(int*);
void printValueByAddress(int*);
int main(void)
{
int number = 5;
int* pointerToNumber = &number;
printAddressToVariable(pointerToNumber);
printValueByAddress(pointerToNumber);
*pointerToNumber += 20;
printValueByAddress(pointerToNumber);
return 0;
}
void printAddressToVariable(int* pointerToNumber)
{
printf("Adres do zmiennej: %p\n", pointerToNumber);
}
void printValueByAddress(int* pointerToNumber)
{
printf("Liczba: %d\n", *pointerToNumber);
}Tłumaczę na szybko całość. Podzieliłem cały kod na prototypy funkcji w ramach utrwalania przez Ciebie dobrej praktyki wydzielania kodu na mniejsze części 🧩. Obie funkcje korzystają z funkcji "printf" i przyjmują jeden parametr typu "wskaźnik do liczby całkowitej" (zobacz, nie do samej liczby całkowitej 👀!).
W środku funkcji "main", definiujemy sobie liczbę 5 i wskaźnik przechowujący adres do tej zmiennej. Następnie wywołujemy funkcje do wypisania aktualnego adresu pamięci, a potem samej wartości "kryjącej się" pod danym adresem.
O wiele bardziej intrygująca może być następująca instrukcja:
*pointerToNumber += 20;Jednym prostym zdaniem: ">>wyłuskaj<< wartość ze wskaźnika i dodaj do niej 20". Operator dereferencji pozwala na korzystanie z operatorów modyfikujących wartość zmiennej, więc taki zapis jest poprawny ✅. To jest to samo, jakbyś napisał(a) tak:
number += 20;A ponieważ wskaźnik jest "skierowany" na tę zmienną, to przy "wyłuskaniu" wartości również będzie "nowa" liczba (po modyfikacji) 😲! To są naczynia połączone, więc przy zmianie wartości danej "podpiętej" pod wskaźnik, zmienna RÓWNIEŻ ulega zmianie!
Wskaźnik w języku C to potężna broń do zarządzania pamięcią dynamicznie (w trakcie działania programu), która pozwala na definiowanie struktur o olbrzymich możliwościach!
To wszystko co widziałbym na wprowadzenie do wskaźników - możesz się domyślać ile jeszcze można o tym napisać 😁!