Poznam Cię teraz z drugim wcieleniem tablic w języku C (wprowadzenie do nich znajduje się tutaj)! Możesz się bardzo zdziwić, gdyż tak naprawdę to nie są tablice, lecz wskaźniki do poszczególnych argumentów 🤯! Koniecznie zobacz jak wygląda tablica jako wskaźnik w języku C oraz jak przebiega obsługa elementów z użyciem notacji wskaźnikowej 😲!

TABLICA JAKO WSKAŹNIK W JĘZYKU C. PODEJŚCIE CAŁKIEM NIETYPOWE

Zachęcam do zapoznania się z pierwszą częścią tematu tablic w języku C, gdyż dalej przedstawiona treść jest kontynuacją ⚠️.

PARĘ ZDAŃ WSTĘPU

Prawdziwa "twarz" kryjąca się pod całkiem przyjaznym zapisem tablicowym, to wskaźnik (o samym wskaźniku więcej przeczytasz tutaj) ℹ️. Wskaźnik to odrębny typ zmiennej przechowujący adres innej zmiennej w pamięci. W językach C i C++, adresy są "wydobywane" ze zmiennych za pomocą "ampersandu" (&). W przypadku tablic nie trzeba ich stosować i zaraz zobaczysz dlaczego 😄.

DOWÓD NA "BYCIE" WSKAŹNIKIEM

Mając przykładową tablicę 👇:

int numbers[4] = {56, 7, 43, -1};

to gdy wstawisz samą nazwę zmiennej:

numbers

uzyskujesz dostęp do adresu pierwszego elementu 👍. Może Cię dziwić, że nie dzieje się nic złego, bez korzystania z operatora do uzyskiwania adresów (&). Ponieważ tablica sama w sobie jest wskaźnikiem, to spełniona jest następująca równość:

numbers = &numbers[0]

Czyli początek tablicy to zarazem adres do jej pierwszego elementu ℹ️. Dlatego w tym przypadku, nie musimy wstawiać ampersandu 💡. Warto zapamiętać tę informację 🧠!

"PRZESUWANIE SIĘ" Z UŻYCIEM ADRESU

Teraz pytanie jak dostajemy się do kolejnych argumentów. Tutaj korzystamy z "arytmetyki wskaźników" polegającej na "przesuwaniu się" o określoną liczbę elementów od początku tablicy.

Najpierw podstawiamy adres do pierwszego elementu (bądź dowolnego innego):

int* addressToCurrentElement = numbers;

Następnie, chcąc się "przesunąć" o następny element w kolejności, wystarczy inkrementować nasz adres:

++addressToCurrentElement;

Teraz zmienna "wskazuje" na element drugi 2️⃣.

W arytmetyce wskaźników, cała sztuka polega na dodawaniu (lub odejmowaniu) o daną liczbę całkowitą, która oznacza o ile elementów wykonać przesunięcie, w jedną lub w drugą stronę ℹ️. W rzeczywistości, to "przesunięcie" odbywa się w bajtach, czyli wartości jaką zwraca operator "sizeof" dowolnego z elementów 👇:

sizeof(numbers[0]);

Zwracam na to uwagę, abyś był(a) tego świadomy(-a) 🫵! Jeżeli prawidłowo przypiszesz adres, czyli do tablicy, a nie zupełnie innej struktury (bo wtedy jest to niezdefiniowane zachowanie 💥!), to zważywszy, że wszystkie elementy są jednakowego typu, to program będzie zawsze "przemieszczał się" prawidłowo (nie dojdzie do sytuacji, w której adres będzie "wskazywał" połowę jednego elementu i połowę drugiego) 👍.

Nie przejmuj się, jeśli teraz się gubisz 🙂. Na przykładzie kodu źródłowego pokażę Ci, w jaki sposób "chodzić" po komórkach w tablicy z użyciem pętli 🔁.

PRZEKAZUJĘ PRZEZ REFERENCJĘ "Z URZĘDU"

Na zakończenie zwrócę uwagę, że skoro tablica w języku C jest wskaźnikiem, to od razu "otrzymuje" formę przekazywania przez referencję 🔥. To oznacza, że zmiany w jej elementach z poziomu innych funkcji poza "main", zostaną utrwalone ✅. Nie ma znaczenia czy zdefiniujemy parametr w postaci wskaźnika, czy tablicy ℹ️.

PRZYKŁAD KODU ŹRÓDŁOWEGO

Kody źródłowe jakie Ci zaprezentuję, będą korzystały z odrębnych funkcji. To dlatego, aby Ci zademonstrować fakt, że tablica jako wskaźnik w języku C może zostać wstawiona do parametru zarówno jako tablica, jak i wskaźnik, i nie ma to żadnego wpływu na działanie programu!

PARAMETR TABLICOWY

Proszę bardzo, oto pierwszy program z parametrem typu "tablica" 👇:

#include <stdio.h>

#define ARRAY_SIZE 4

void multiplyNumbers(int[], int);
void printNumbers(int[]);

int main(void)
{
	int numbers[ARRAY_SIZE] = {56, 7, 43, -1};

	multiplyNumbers(numbers, 2);
	printNumbers(numbers);

	return 0;
}

void multiplyNumbers(int numbers[], int multiplier)
{
	for (int i = 0; i < ARRAY_SIZE; ++i)
	{
		numbers[i] *= multiplier;
	}
}

void printNumbers(int numbers[])
{
	for (int i = 0; i < ARRAY_SIZE; ++i)
	{
		printf("Liczba %d = %d\n", i + 1, numbers[i]);
	}
}

Prześledźmy sobie wszystko na spokojnie 😅.

U samej góry dorzucamy sobie stałą "#define" do określenia w jednym miejscu wartości oznaczającej rozmiar tablicy, który jak już wiemy, pozostaje definitywny przez cały cykl jej życia 🙂. Następne 2 wiersze, to prototypy funkcji do naszego eksperymentu. Zauważ, że w obu przypadkach korzystamy z tablicy jako typu pierwszego parametru. To umożliwia nam korzystanie z notacji tablicowej w kontekście elementów tablicy wewnątrz funkcji ✅.

Dalej mamy definicje funkcji "main" oraz 2 pozostałych utworzonych dla zobrazowania naszego przykładu. Warto wiedzieć, że mając zmienną do przemieszczania się "indeksowo", możesz odwoływać się do poszczególnych elementów nie tylko w ten sposób:

numbers[i]

lecz także używając notacji wskaźnikowej, poprzez operator "wyłuskania":

*(numbers + i)

Temat notacji wskaźnikowej przedstawiam w odrębnym materiale 📜.

Gdy poddasz program kompilacji, to zauważysz wypisanie wszystkich 4 liczb przemnożonych przez 2, czyli mamy dowód na przekazywanie przez referencję 👍.

PARAMETR WSKAŹNIKOWY

A teraz drugi przykład, który może Ci zawrócić w głowie 😅. Teraz funkcje akceptujące tablicę, lecz z podstawieniem pod wskaźniki 👇:

#include <stdio.h>

#define ARRAY_SIZE 4

void multiplyNumbers(int*, int*, int);
void printNumbers(int*, int*);

int main(void)
{
	int numbers[ARRAY_SIZE] = {56, 7, 43, -1};
	int* numbersEndAddress = numbers + ARRAY_SIZE;

	multiplyNumbers(numbers, numbersEndAddress, 2);
	printNumbers(numbers, numbersEndAddress);

	return 0;
}

void multiplyNumbers(int* startAddress, int* endAddress, int multiplier)
{
	for (; startAddress < endAddress; ++startAddress)
	{
		*startAddress *= multiplier;
	}
}

void printNumbers(int* startAddress, int* endAddress)
{
	for (int i = 1; startAddress < endAddress; (++i, ++startAddress))
	{
		printf("Liczba %d = %d\n", i, *startAddress);
	}
}

Mamy zupełnie inne listy parametrów 💥! Zwróć uwagę także na ich liczbę - o jeden więcej! Czas na odpowiedź na pytanie: "w jaki sposób, z użyciem wskaźnika, ustalić gdzie znajduje się koniec tablicy?".

Pamiętasz co napisałem na temat "przesuwania się" od początku tablicy 😊? Mając adres początku tablicy, który "kryje się" za nazwą zmiennej tablicowej (bo tablica sama w sobie jest adresem, więc przy wprowadzaniu jej do funkcji nie trzeba dodawać "ampersandu" tuż przed jej nazwą):

numbers

możemy bez problemu wyznaczyć jej koniec dodając do adresu początkowego...liczbę elementów zawartych w tablicy 💡! Chociaż to jest taki skrót myślowy, bo tak naprawdę, to dodajemy wartość stałej pomnożoną przez liczbę bajtów typu danych tej tablicy 😱. W tym przypadku jest to "int", czyli 4 bajty przy systemie 32-bitowym, co daje 16 bajtów odstępu od pierwszego argumentu.

Spójrz na to:

int* numbersEndAddress = numbers + ARRAY_SIZE;

To jest adres pamięci do wartości spoza tablicy! Przypominam, że ostatni element będzie zawierał indeks równy (N - 1), czyli liczba elementów zmniejszona o 1. Tu do adresu początkowego dodajemy liczbę elementów, a więc to będzie wyznacznik końca iterowania po danych tablicy ✅.

Z tego powodu, potrzebne są DWA parametry - jeden wyznacza wskaźnik do pierwszego argumentu tablicy, a drugi prowadzi do wskaźnika do pierwszego adresu wykraczającego poza zakres tablicy. Ponieważ w funkcji występuje pętla "for", musimy mieć warunek zakończenia, gdyż poruszając się po adresach, nie dysponujemy żadnym indeksem i trzeba o to zadbać samodzielnie.

Skoro dotarliśmy do pętli, to zobacz kolejną ważną rzecz 👀! Sama postać pętli ma w sobie dużo nietypowych zmian! Przyjrzyj się 😄:

for (; startAddress < endAddress; ++startAddress)
{
	*startAddress *= multiplier;
}

W pierwszej pętli nie ma w ogóle inicjowania zmiennej iteracyjnej 🔥!!! Tu polegamy na samych adresach podanych jako parametry! Warunek za to jest ten sam - powtarzaj, dopóki adres początkowy nie "zrówna się" z adresem końcowym. A po ludzku: "dopóki adres odpowiedzialny za iterowanie po elementach, nie dojdzie do końca tablicy".

Adres inkrementujemy (czyli "przesuwamy się" o 4 bajty do przodu), co jest równoważne "przesunięciu się" w notacji tablicowej o jeden element do przodu, a w środku pętli mamy "wyłuskanie" wartości z aktualnego adresu pamięci i przemnożenie przez podany w funkcji mnożnik 🧨.

Teraz analizujemy drugą pętlę:

for (int i = 1; startAddress < endAddress; (++i, ++startAddress))
{
	printf("Liczba %d = %d\n", i, *startAddress);
}

Tym razem zmienna iteracyjna jest na swoim miejscu, aczkolwiek nie ma nic wspólnego z warunkiem zakończenia! Ona jest tam tylko po to, aby w środku funkcji "printf", poza wypisaniem "wyłuskanej" wartości elementu, wstawić numer argumentu w tablicy 🙂. Co każdy przebieg pętli, inkrementujemy OBIE wartości i zobacz, że te inkrementacje zostały objęte nawiasem. To ma na celu wymusić kolejność wykonywania operacji i zapewnić, że obie te części zostaną wykonane, zanim pętla przejdzie do następnej iteracji.

Obsługa tablicy jako wskaźnik w języku C

Tablica w języku C, to w rzeczywistości wskaźnik przechowujący adres do każdego z elementów.


Koniec 😅. Widzimy gołym okiem, że tablica jako wskaźnik w języku C, kryje dużo czarniejszą strukturę ⬛. Wskaźniki i adresy oraz umiejętne manipulowanie nimi to temat mogący spędzić sen z powiek 💣. Jeżeli chcesz pisać programy w językach C i C++ w stopniu biegłym, to temat zarządzania pamięcią, musisz mieć w małym palcu!

PODOBNE ARTYKUŁY