Tablice i wskaźniki to w C różne rzeczy, które przez większość czasu zachowują się tak samo. To "przez większość czasu" sprawia, że temat jest śliski: kod, który traktuje tablicę jak wskaźnik, zwykle działa, aż do dnia, w którym trafi w jedno z dwóch miejsc, gdzie różnica ma znaczenie, a odpowiedź jest po cichu błędna.
Ta strona precyzyjnie wyznacza tę granicę.
Degradacja tablicy: jedna reguła, która stoi za wszystkim
Prawie w każdym wyrażeniu nazwa tablicy jest automatycznie zamieniana na wskaźnik na jej pierwszy element. Nazywa się to degradacją (decay). arr staje się &arr[0], typu int *.
Nazwa tablicy i adres jej pierwszego elementu wypisują się identycznie. To degradacja w działaniu i dlatego możesz przypisać tablicę do wskaźnika bez &.
Są dokładnie trzy miejsca, w których degradacja nie zachodzi: jako operand sizeof, jako operand & i przy inicjalizacji tablicy znaków literałem łańcuchowym. Wszędzie indziej zakładaj degradację.
arr[i] to dosłownie *(arr + i)
Standard C definiuje operator indeksowania za pomocą arytmetyki wskaźników: a[b] oznacza *(a + b). Indeksowanie nie jest osobną funkcją języka, tylko skrótem.
Wynikają z tego dwie konsekwencje. Po pierwsze, wskaźnik można indeksować: p[2] to *(p + 2), i dlatego funkcje, które dostają wskaźnik, wciąż mogą używać znajomej składni arr[i]. Po drugie, 2[arr] się kompiluje: *(2 + arr) to ten sam adres co *(arr + 2). Nikt tak nie pisze poza quizami, ale to rozstrzyga, czy indeksowanie to "naprawdę" arytmetyka wskaźników.
Przekazywanie tablicy do funkcji
Z powodu degradacji funkcja nigdy nie dostaje tablicy. Dostaje wskaźnik.
Z tego przykładu warto wyciągnąć trzy rzeczy.
Rozmiar w int arr[100] to tylko dokumentacja. Kompilator przepisuje parametr na int *arr i nigdy nie sprawdza, czy przekazano 100 czegokolwiek.
Długość musi wędrować osobno. Funkcja ma adres i nie ma pojęcia, jak daleko sięgają dane. Dlatego praktycznie każda funkcja C przyjmująca tablicę przyjmuje też liczbę elementów: memcpy(dst, src, n), fread(ptr, size, count, f), qsort(base, nmemb, size, cmp). Łańcuchy są wyjątkiem tylko dlatego, że niosą własny znacznik końca, '\0'.
Przekazanie wskaźnika jest tanie, a dane są współdzielone. Nie powstaje żadna kopia tablicy, więc funkcja może zmieniać elementy wywołującego, i często właśnie o to chodzi:
Oznaczaj parametry tylko do odczytu jako const int *, tak jak robi to print_all. To dokumentuje intencję i pozwala kompilatorowi wyłapać przypadkowy zapis.
Pułapka sizeof
Oto błąd, w który każdy programista C wpada raz. sizeof to jedno z dwóch miejsc, w których degradacja nie zachodzi, więc daje prawdziwy rozmiar tablicy tam, gdzie tablica jest zadeklarowana, i rozmiar wskaźnika wszędzie tam, gdzie tablica uległa degradacji.
W main sizeof data to 40 bajtów, a długość wychodzi 10. Wewnątrz inspect sizeof arr to rozmiar wskaźnika, czyli 8 na maszynie 64-bitowej, a "długość" wychodzi 2. Kod wygląda identycznie i jest po cichu błędny.
Wynikająca z tego reguła: obliczaj długość tablicy tylko w zasięgu, w którym tablica została zadeklarowana, i przekazuj ją stamtąd dalej.
& tablicy to nie ten sam typ
Drugim miejscem, w którym degradacja nie zachodzi, jest operator &. &arr to wskaźnik na całą tablicę, a nie na jej pierwszy element. Oba przechowują ten sam adres; różnica polega na tym, co znaczy "dodaj 1".
Ten sam adres startowy, inny krok. arr ma typ int * i przesuwa się o jeden element; &arr ma typ int (*)[5] i przesuwa się o całą tablicę. Na tym mechanizmie opiera się przekazywanie wiersza tablicy 2D, opisane na stronie o tablicach wielowymiarowych.
Wskaźnik na tablicę a tablica wskaźników
Dwie deklaracje, które wyglądają podobnie, a znaczą coś przeciwnego:
int *p[5]; // tablica 5 wskaźników na int
int (*q)[5]; // wskaźnik na tablicę 5 intów
[] ma wyższy priorytet niż *, więc int *p[5] czyta się jako "p to tablica wskaźników". Nawiasy w int (*q)[5] wymuszają, żeby * związało się najpierw: "q to wskaźnik na tablicę".
int *p[5] int (*q)[5]
p[0] -> [ int ] q -> [ int | int | int | int | int ]
p[1] -> [ int ] jeden ciągły blok 5 elementów
p[2] -> [ int ]
p[3] -> [ int ]
p[4] -> [ int ]
pięć osobnych adresów
Tablica wskaźników to ta częstsza i już jej używano: char *argv[] to tablica wskaźników na łańcuchy, po jednym na każdy argument wiersza poleceń. Tak też przechowuje się listę łańcuchów o różnych długościach bez marnowania miejsca.
Zwróć uwagę na (*q)[0]: wyłuskaj wskaźnik, żeby dostać tablicę, a potem ją zaindeksuj. Nawiasy znowu są wymagane, z tego samego powodu związanego z priorytetem.
Gdzie tablice i wskaźniki naprawdę się różnią
Miej w głowie tę krótką tabelę, a zamieszanie zniknie:
Tablica int arr[10] | Wskaźnik int *p | |
|---|---|---|
| Czym jest | 10 intów ułożonych w pamięci | jedna zmienna przechowująca adres |
sizeof | 40: cały blok | 8: tylko wskaźnik |
| Można przypisać | nie: arr = x; to błąd | tak: p = x; jest w porządku |
& daje | int (*)[10] | int ** |
| Skąd pochodzi pamięć | zadeklarowana razem z tablicą | stamtąd, gdzie go skierujesz |
Nazwa tablicy nie jest zmienną przechowującą adres: jest pamięcią, a adres oblicza się na żądanie. Dlatego nie można do niej przypisać.
Najczęściej zadawane pytania
Czy tablice i wskaźniki są w C tym samym?
Nie, ale łatwo je pomylić, bo nazwa tablicy prawie w każdym wyrażeniu degraduje się do wskaźnika na pierwszy element. Sama tablica to blok elementów o znanym rozmiarze; wskaźnik to pojedyncza zmienna przechowująca jeden adres. Różnica ujawnia się w dwóch miejscach: przy sizeof i przy &.
Dlaczego sizeof(arr) daje wewnątrz funkcji zły wynik?
Bo parametr nie jest tablicą. void f(int arr[]) jest po cichu przepisywane na void f(int *arr), więc sizeof(arr) mierzy wskaźnik (na większości maszyn 8 bajtów), a nie oryginalną tablicę. Przekazuj długość jako osobny parametr.
Czym różni się int *p[5] od int (*p)[5]?
int *p[5] to tablica 5 wskaźników na int. int (*p)[5] to pojedynczy wskaźnik na tablicę 5 intów. Nawiasy najpierw wiążą * z nazwą; bez nich wygrywa [], bo ma wyższy priorytet.
Czy arr[i] to naprawdę to samo co *(arr + i)?
Tak: standard definiuje a[b] jako *(a + b). Dlatego też dziwnie wyglądające i[arr] kompiluje się i działa: dodawanie jest przemienne, więc *(i + arr) to ten sam element. Nigdy nie pisz tak w prawdziwym kodzie, ale to dowodzi reguły.