Dlaczego wskaźniki przyrostowe?

25

Niedawno zacząłem uczyć się C ++ i jak większość ludzi (zgodnie z tym, co czytałem) mam problemy ze wskaźnikami.

Nie w tradycyjnym tego słowa znaczeniu, rozumiem czym one są i dlaczego są używane oraz w jaki sposób mogą być przydatne, jednak nie rozumiem, w jaki sposób przydatne byłyby zwiększanie wskaźników, czy ktoś może wyjaśnić, w jaki sposób zwiększanie wskaźnika jest przydatna koncepcja i idiomatyczny C ++?

To pytanie pojawiło się po tym, jak zacząłem czytać książkę A Tour of C ++ autorstwa Bjarne Stroustrup, polecono mi tę książkę, ponieważ znam się na Javie, a chłopaki z Reddit powiedzieli mi, że będzie to dobra książka do przejścia .

INdek
źródło
11
Wskaźnik jest tylko iteratorem
Charles Salvia
1
Jest to jedno z ulubionych narzędzi do pisania wirusów komputerowych, które czytają to, czego nie powinny czytać. Jest to również jeden z najczęstszych przypadków podatności na zagrożenia w aplikacjach (gdy zwiększa się wskaźnik poza obszar, w którym powinien, a następnie czyta lub zapisuje)> Zobacz błąd HeartBleed.
Sam
1
@vasile To jest złe w przypadku wskaźników.
Cruncher
4
Zaletą / zaletą C ++ jest to, że pozwala on zrobić znacznie więcej przed wywołaniem segfault. Zwykle występuje błąd podczas próby uzyskania dostępu do pamięci innego procesu, pamięci systemowej lub chronionej pamięci aplikacji. Dowolny dostęp do zwykłych stron aplikacji jest dozwolony przez system i zależy to od programisty / kompilatora / języka, aby egzekwować rozsądne limity. C ++ praktycznie pozwala ci robić, co chcesz. Jeśli chodzi o openssl posiadający własnego menedżera pamięci - to nieprawda. Ma tylko domyślne mechanizmy dostępu do pamięci C ++.
Sam
1
@INdek: Dostaniesz segfault tylko wtedy, gdy pamięć, do której próbujesz uzyskać dostęp, jest chroniona. Większość systemów operacyjnych przypisuje ochronę na poziomie strony, więc zwykle można uzyskać dostęp do wszystkiego, co znajduje się na stronie, na której zaczyna się wskaźnik. Jeśli system operacyjny korzysta z rozmiaru strony 4K, to duża ilość danych. Jeśli wskaźnik zaczyna się gdzieś na stosie, nikt nie zgadnie, do ilu danych można uzyskać dostęp.
TMN

Odpowiedzi:

46

Gdy masz tablicę, możesz ustawić wskaźnik wskazujący na element tablicy:

int a[10];
int *p = &a[0];

Tutaj pwskazuje na pierwszy element a, którym jest a[0]. Teraz możesz zwiększyć wskaźnik, aby wskazywał następny element:

p++;

Teraz pzwraca się do drugiego elementu a[1]. Możesz uzyskać dostęp do elementu tutaj za pomocą *p. Różni się to od języka Java, w którym należy użyć zmiennej indeksu liczb całkowitych, aby uzyskać dostęp do elementów tablicy.

Zwiększanie wskaźnika w C ++, gdy wskaźnik ten nie wskazuje na element tablicy, jest niezdefiniowanym zachowaniem .

Greg Hewgill
źródło
23
Tak, w C ++ jesteś odpowiedzialny za unikanie błędów programowania, takich jak dostęp poza granice tablicy.
Greg Hewgill
9
Nie, zwiększenie wskaźnika, który wskazuje na cokolwiek oprócz elementu tablicy, jest niezdefiniowanym zachowaniem. Jeśli jednak robisz coś na niskim poziomie, a nie przenośnym, to zwiększanie wskaźnika zwykle jest niczym innym jak dostępem do następnej rzeczy w pamięci, cokolwiek by się nie wydarzyło.
Greg Hewgill
4
Jest kilka rzeczy, które są lub mogą być traktowane jako tablica; ciąg tekstu jest w rzeczywistości tablicą znaków. W niektórych przypadkach długi int jest traktowany jako tablica bajtów, chociaż może to łatwo wpędzić cię w kłopoty.
AMADANON Inc.,
6
Mówi to o typie , ale zachowanie opisano w 5.7 Operatory addytywne [expr.add]. W szczególności 5.7 / 5 mówi, że wyjście poza dowolne miejsce poza tablicą z wyjątkiem one-past-the-end to UB.
Bezużyteczne
4
Ostatni akapit brzmi: jeśli zarówno operand wskaźnika, jak i wynik wskazują na elementy tego samego obiektu tablicy, ocena nie spowoduje przepełnienia; w przeciwnym razie zachowanie jest niezdefiniowane . Tak więc, jeśli wynik nie jest ani w tablicy ani jeden za końcem, otrzymasz UB.
Bezużyteczne
37

Zwiększanie wskaźników to idiomatyczne C ++, ponieważ semantyka wskaźników odzwierciedla podstawowy aspekt filozofii projektowania stojącej za standardową biblioteką C ++ (opartą na STL Aleksandra Stepanowa )

Ważną koncepcją jest to, że STL jest zaprojektowany wokół kontenerów, algorytmów i iteratorów. Wskaźniki są po prostu iteratorami .

Oczywiście, zdolność do zwiększania (lub dodawania / odejmowania) wskaźników wraca do C. Wiele algorytmów manipulacji ciągiem C można zapisać po prostu za pomocą arytmetyki wskaźnika. Rozważ następujący kod:

char string1[4] = "abc";
char string2[4];
char* src = string1;
char* dest = string2;
while ((*dest++ = *src++));

Ten kod używa arytmetyki wskaźnika do kopiowania łańcucha C zakończonego znakiem null. Pętla automatycznie kończy się, gdy napotka zero.

W C ++ semantyka wskaźników jest uogólniona na pojęcie iteratorów . Większość standardowych kontenerów C ++ zapewnia iteratory, do których można uzyskać dostęp za pomocą funkcji begini endczłonków. Iteratory zachowują się jak wskaźniki, ponieważ mogą być zwiększane, usuwane, a czasem zmniejszane lub rozszerzane.

Aby powtórzyć std::string, powiedzielibyśmy:

std::string s = "abcdef";
std::string::iterator it = s.begin();
for (; it != s.end(); ++it) std::cout << *it;

Zwiększamy iterator tak samo, jak zwiększamy wskaźnik do zwykłego ciągu C. Powodem, dla którego ta koncepcja jest potężna, jest to, że można używać szablonów do pisania funkcji, które będą działać dla dowolnego typu iteratora, który spełnia niezbędne wymagania dotyczące koncepcji. A to jest siła STL:

std::string s1 = "abcdef";
std::vector<char> buf;
std::copy(s1.begin(), s1.end(), std::back_inserter(buf));

Ten kod kopiuje ciąg do wektora. Ta copyfunkcja jest szablonem, który będzie działał z dowolnym iteratorem obsługującym inkrementację (w tym zwykłe wskaźniki). Możemy użyć tej samej copyfunkcji na zwykłym łańcuchu C:

   const char* s1 = "abcdef";
   std::vector<char> buf;
   std::copy(s1, s1 + std::strlen(s1), std::back_inserter(buf));

Mogliśmy korzystać copyna zasadzie std::mapalbo std::setczy jakiejkolwiek niestandardowego kontenera, który obsługuje iteratorów.

Zauważ, że wskaźniki są specyficznym typem iteratora: iterator o dostępie swobodnym , co oznacza, że ​​obsługują one zwiększanie, zmniejszanie i przyspieszanie z operatorem +i -. Inne typy iteratorów obsługują tylko podzbiór semantyki wskaźnika: dwukierunkowy iterator obsługuje co najmniej zwiększanie i zmniejszanie; A forward iteracyjnej podpory przynajmniej zwiększany. (Wszystkie typy iteratorów obsługują dereferencje). Ta copyfunkcja wymaga iteratora, który przynajmniej obsługuje inkrementację.

Możesz przeczytać o różnych koncepcjach iteratora tutaj .

Zatem zwiększanie wskaźników jest idiomatycznym sposobem C ++ do iteracji po tablicy C lub uzyskiwania dostępu do elementów / przesunięć w tablicy C.

Charles Salvia
źródło
3
Chociaż używam wskaźników jak w pierwszym przykładzie, nigdy nie myślałem o tym jako o iteratorze, teraz ma to sens.
dyesdyes
1
„Pętla kończy się automatycznie, gdy napotka zero.” To przerażający idiom.
Charles Wood
9
@CharlesWood, więc chyba musisz znaleźć C dość przerażające
Siler
7
@CharlesWood: Alternatywą jest użycie długości łańcucha jako zmiennej sterującej pętli, co oznacza dwukrotne przejście łańcucha (raz, aby określić długość, a raz, aby skopiować znaki). Kiedy używasz PDP-7 1MHz, to naprawdę może zacząć się sumować.
TMN
3
@INdek: po pierwsze, C i C ++ starają się za wszelką cenę uniknąć przełomowych zmian - i powiedziałbym, że zmiana domyślnego zachowania literałów łańcuchowych byłaby dość modyfikacją. Ale co najważniejsze, ciągi zerowane są po prostu konwencją (łatwą do naśladowania przez fakt, że literały łańcuchowe są domyślnie kończone na zero i że funkcje biblioteczne ich oczekują), nikt nie powstrzymuje cię przed użyciem liczonych ciągów w C - w rzeczywistości, korzysta z nich kilka bibliotek C (patrz np. BLE OLE).
Matteo Italia
16

Arytmetyka wskaźnika jest w C ++, ponieważ była w C. Arytmetyka wskaźnika jest w C, ponieważ jest to zwykły idiom w asemblerze .

Istnieje wiele systemów, w których „rejestr przyrostów” jest szybszy niż „ładowanie stałej wartości 1 i dodawanie do rejestru”. Co więcej, wiele systemów pozwala „załadować DWORD do A z adresu określonego w rejestrze B, a następnie dodać sizeof (DWORD) do B” w jednej instrukcji. W dzisiejszych czasach możesz spodziewać się optymalizującego kompilatora, który to rozwiązuje, ale tak naprawdę nie było takiej możliwości w 1973 roku.

Jest to w zasadzie ten sam powód, dla którego tablice C nie są sprawdzane pod kątem granic, a łańcuchy C nie mają w sobie osadzonego rozmiaru: język został opracowany w systemie, w którym liczy się każdy bajt i każda instrukcja.

pjc50
źródło