Wskaźniki przechowują adresy, a adresy to liczby, więc rozsądnie jest oczekiwać, że da się na nich wykonywać arytmetykę. Da się, ale nie całkiem tak jak na zwykłych liczbach całkowitych. C skaluje każdą operację na wskaźniku przez rozmiar tego, na co wskazuje, co zamienia "dodaj jeden" w dużo bardziej przydatne "przejdź do następnego elementu".
Właśnie dzięki tej jednej decyzji projektowej przechodzenie tablicy wskaźnikiem jest w C równie naturalne jak przechodzenie indeksem.
Dodanie 1 przesuwa o jeden obiekt, a nie o jeden bajt
Weź wskaźnik, dodaj 1 i wypisz oba adresy:
int * skacze o 4 bajty, double * o 8, char * o 1: każdy ląduje dokładnie tam, gdzie zaczynałby się następny obiekt jego typu. Reguła jest prosta:
ptr + n == (adres w ptr) + n * sizeof(*ptr)
Właśnie dlatego wskaźnik w ogóle ma typ. Goły adres bez typu nie wiedziałby, jak daleko jest "następny".
Przechodzenie tablicy wskaźnikiem
Teraz korzyść. Elementy tablicy leżą w pamięci jeden po drugim, więc przesuwanie wskaźnika odwiedza je po kolei:
Obie pętle wypisują te same pięć liczb. W drugiej scores degraduje się do wskaźnika na pierwszy element, p++ przesuwa się o jeden int, a *p odczytuje element, który tam leży. W pamięci wygląda to tak:
scores[0] scores[1] scores[2] scores[3] scores[4]
+---------+---------+---------+---------+---------+
| 88 | 92 | 75 | 60 | 100 |
+---------+---------+---------+---------+---------+
^ ^ ^
p p+1 scores+5
(start) (4 bajty dalej) (tuż za końcem)
Związek między obiema formami jest dokładny i warto go zapamiętać: scores[i] jest zdefiniowane jako *(scores + i). Więcej o tym na stronie o wskaźnikach i tablicach.
Inkrementacja, dekrementacja i pułapka *p++
++ i -- działają na wskaźnikach z tym samym skalowaniem.
Wyrażenie, które myli wszystkich, to *p++. Przyrostkowe ++ wiąże mocniej niż *, więc oznacza to "weź bieżące p, przesuń p, a potem wyłuskaj starą wartość":
Trzy wyrażenia, trzy różne znaczenia:
| Wyrażenie | Wskaźnik potem | Uzyskana wartość |
|---|---|---|
*p++ | przesunięty | element sprzed przesunięcia |
*++p | przesunięty | element po przesunięciu |
(*p)++ | bez zmian | stara wartość, a element zostaje zwiększony |
*p++ to idiomatyczne C, tak pisze się kopiowanie łańcuchów i buforów, ale gdy chodzi ci o cokolwiek innego, dopisz nawiasy.
Odejmowanie dwóch wskaźników
Odjęcie jednego wskaźnika od drugiego daje liczbę elementów między nimi, a nie liczbę bajtów:
Typem wyniku jest ptrdiff_t, typ całkowity ze znakiem z <stddef.h>, wypisywany przez %td. Odejmowanie wskaźników do dwóch różnych tablic daje niezdefiniowany wynik: standard definiuje tę operację tylko w obrębie jednego obiektu.
Od wskaźnika można też odjąć liczbę całkowitą (p - 3 cofa się o trzy elementy), ale nie można dodać do siebie dwóch wskaźników. "Suma dwóch adresów" niczego nie oznacza.
Porównywanie wskaźników
Operatory relacyjne działają na wskaźnikach w obrębie tej samej tablicy i znaczą dokładnie to, co sugeruje układ pamięci: p < q jest prawdą, gdy p wskazuje wcześniejszy element.
front < back to cały warunek zakończenia tej pętli i jest poprawny, bo oba wskaźniki pozostają w jednej tablicy. == i != to też naturalny sposób porównania pętli z jej znacznikiem końca, co prowadzi do reguły, dzięki której to wszystko jest dozwolone.
Reguła pozycji tuż za końcem
C wprost pozwala utworzyć wskaźnik na pozycję tuż za ostatnim elementem tablicy. Możesz go obliczyć, przechować i porównywać. Nie możesz go wyłuskać.
int arr[4];
arr[0] arr[1] arr[2] arr[3] (brak elementu)
+------+ +------+ +------+ +------+ +- - - -+
| | | | | | | | | |
+------+ +------+ +------+ +------+ +- - - -+
^ ^
arr arr + 4
wolno używać wolno UTWORZYĆ i PORÓWNYWAĆ
nigdy nie wolno wyłuskać
Ta reguła sprawia, że standardowy kształt pętli jest poprawny:
for (int *p = arr; p != arr + n; p++) {
/* ... */
}
W ostatniej iteracji p staje się arr + n, porównanie zawodzi i pętla się kończy, nigdy nie odczytując tej pozycji.
Mimo to dwie rzeczy pozostają niezdefiniowane. Utworzenie wskaźnika dwie pozycje za końcem albo jedną przed początkiem to niezdefiniowane zachowanie, nawet jeśli nigdy go nie wyłuskasz:
int *bad1 = arr + n + 1; // niezdefiniowane: za daleko za końcem
int *bad2 = arr - 1; // niezdefiniowane: przed początkiem
Ten drugi przypadek ma znaczenie w praktyce: pętla wsteczna zapisana jako for (int *p = arr + n - 1; p >= arr; p--) przy ostatniej dekrementacji oblicza arr - 1. Działa na każdym popularnym kompilatorze, a mimo to formalnie jest niezdefiniowana. Czysta pętla wsteczna tego unika:
Wskaźniki void i arytmetyka, której nie wolno robić
void * przechowuje adres bez typu i dzięki temu malloc i qsort pozostają ogólne. Ponieważ nie ma rozmiaru elementu, standard nie pozwala na arytmetykę na void *: najpierw zrzutuj na konkretny typ (albo na char * do pracy na bajtach).
void *v = buffer;
// v + 1; // to nie jest standardowe C: nie ma przez co skalować
char *b = v;
b + 1; // w porządku: jeden bajt do przodu
GCC i Clang akceptują arytmetykę na void * jako rozszerzenie, które traktuje go jak char *, więc kod, który na tym polega, kompiluje się aż do dnia, w którym zostanie zbudowany gdzieś bardziej rygorystycznie. Kompiluj z -std=c17 -pedantic, jeśli chcesz o tym wiedzieć.
Co to daje
Arytmetyka wskaźników to nie sztuczka optymalizacyjna, po którą sięga się od czasu do czasu, tylko mechanizm pod indeksowaniem tablic, obsługą łańcuchów i każdym przejściem bufora w bibliotece standardowej. strlen to wskaźnik przesunięty do kończącego '\0' i odjęty od początku. memcpy to dwa wskaźniki idące krok w krok.
Dalej zobacz, jak naprawdę działa równoważność tablic i wskaźników, łącznie z pułapką sizeof, w którą każdy wpada przy pierwszym przekazaniu tablicy do funkcji: strona o wskaźnikach i tablicach.
Najczęściej zadawane pytania
Co robi ptr + 1 w C?
Przesuwa wskaźnik do przodu o jeden obiekt, a nie o jeden bajt. Dla int * na typowej maszynie to 4 bajty, dla double * 8. Kompilator mnoży za ciebie przez sizeof(*ptr) i dlatego typ wskaźnika ma znaczenie.
Czy w C można odjąć od siebie dwa wskaźniki?
Tak, gdy oba wskazują w obrębie tej samej tablicy. end - start daje liczbę elementów między nimi, typu ptrdiff_t (wypisuje się go przez %td). Odejmowanie wskaźników na niezwiązane obiekty to niezdefiniowane zachowanie.
Czy wolno wskazywać o jeden element za końcem tablicy?
Tak: utworzenie wskaźnika na pozycję tuż za ostatnim elementem jest wprost dozwolone, żeby działały pętle typu for (int *p = arr; p != arr + n; p++). Taki wskaźnik możesz obliczyć i porównywać, ale jego wyłuskanie to niezdefiniowane zachowanie.
Dlaczego *p++ to nie to samo co (*p)++?
*p++ zwiększa wskaźnik i wyłuskuje starą wartość: odczytuje bieżący element, a potem przesuwa się dalej. (*p)++ zostawia wskaźnik w spokoju i zwiększa wartość, na którą wskazuje. Przyrostkowe ++ wiąże mocniej niż *, więc to nawiasy zmieniają znaczenie.