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 👍.
CZYM JEST WSKAŹNIK W JĘZYKU C?
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) 💣!
JAKIE SĄ PRAKTYCZNE ZASTOSOWANIA WSKAŹNIKÓW W JĘZYKU C?
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, aby 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 🔥!
Przejdźmy teraz do zaznajamiania się ze wskaźnikiem od strony kodu 📖.
JAK UTWORZYĆ WSKAŹNIK W JĘZYKU C?
Wskaźniki w języku C 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 💥!
Pierwsza rzecz jaką musisz przyswoić, to to, by od razu definiować wskaźnik! Tylko jak to zrobić?
JAK POPRAWNIE ZDEFINIOWAĆ WSKAŹNIK W JĘZYKU C?
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 😲!
JAK WYŚWIETLIĆ ADRESU PAMIĘCI WSKAŹNIKA W JĘZYKU C?
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 ℹ️.
JAK UZYSKAĆ DOSTĘP DO WARTOŚCI ZMIENNEJ 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" (to już nie jest wskaźnik) 💡.
CZY "GWIAZDKA" W ZMIENNEJ WSKAŹNIKOWEJ POWINNA BYĆ UMIESZCZONA 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 (a łatwo można się na tym złapać 😅):
int* pointerToNumber, *pointerToNumberB;Wtedy może być lepiej zostawienie asterysku "przyklejonego" do nazwy 😉.
PRZYKŁAD KODU ŹRÓDŁOWEGO DZIAŁANIA WSKAŹNIKA W JĘZYKU C
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ć 😁!