Menu

Wskaźniki i tablice w C: degradacja, arr[i] = *(arr+i) i pułapka sizeof

W C nazwa tablicy prawie wszędzie, gdzie jej użyjesz, zamienia się we wskaźnik na pierwszy element. Ta strona wyjaśnia tę degradację, dlaczego arr[i] to dosłownie *(arr+i), dlaczego rozmiar tablicy musi trafiać do funkcji osobno i czym wskaźnik na tablicę różni się od tablicy wskaźników.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

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 jest10 intów ułożonych w pamięcijedna zmienna przechowująca adres
sizeof40: cały blok8: tylko wskaźnik
Można przypisaćnie: arr = x; to błądtak: p = x; jest w porządku
& dajeint (*)[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.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ