Menu

Arytmetyka wskaźników w C: dodawanie, odejmowanie i poruszanie się po pamięci

Dodanie 1 do wskaźnika nie dodaje 1 bajtu, tylko przesuwa go do następnego obiektu danego typu. Ta strona omawia ptr+1, inkrementację i dekrementację, różnicę wskaźników, porównania, przechodzenie tablicy wskaźnikiem i regułę pozycji tuż za końcem.

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

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żenieWskaźnik potemUzyskana wartość
*p++przesuniętyelement sprzed przesunięcia
*++pprzesuniętyelement po przesunięciu
(*p)++bez zmianstara 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.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ