Czy inkrementacja wskaźnika do tablicy dynamicznej o rozmiarze 0 jest niezdefiniowana?

34

AFAIK, chociaż nie możemy stworzyć macierzy pamięci statycznej o rozmiarze 0, ale możemy to zrobić za pomocą dynamicznych:

int a[0]{}; // Compile-time error
int* p = new int[0]; // Is well-defined

Jak czytałem, pdziała jak element jednego końca. Mogę wydrukować adres, który pwskazuje.

if(p)
    cout << p << endl;
  • Chociaż jestem pewien, że nie możemy wyłapać tego wskaźnika (element ostatni-ostatni), tak jak nie możemy z iteratorami (element ostatni-ostatni), ale nie jestem pewien, czy zwiększać ten wskaźnik p? Czy zachowanie niezdefiniowane (UB) jest podobne do iteratorów?

    p++; // UB?
Itachi Uchiwa
źródło
4
UB „... Wszelkie inne sytuacje (tj. Próby wygenerowania wskaźnika, który nie wskazuje na element tej samej tablicy lub jeden za końcem) wywołują niezdefiniowane zachowanie ....” z: en.cppreference.com / w / cpp / language / operator_arithmetic
Richard Critten
3
Jest to podobne do przedmiotu std::vectorz 0 pozycjami. begin()jest już równy, end()więc nie można zwiększyć iteratora, który wskazuje na początek.
Phil1970,
1
@PeterMortensen Myślę, że twoja edycja zmieniła znaczenie ostatniego zdania („Czego jestem pewien -> nie jestem pewien, dlaczego”), czy mógłbyś dokładnie sprawdzić?
Fabio mówi Przywróć Monikę
@PeterMortensen: Ostatni edytowany akapit stał się nieco mniej czytelny.
Itachi Uchiwa,

Odpowiedzi:

32

Wskaźniki do elementów tablic mogą wskazywać na prawidłowy element lub jeden za końcem. Jeśli zwiększysz wskaźnik w sposób, który przekracza więcej niż jeden koniec, zachowanie jest niezdefiniowane.

W przypadku tablicy o rozmiarze 0 pwskazuje ona już jeden koniec, więc zwiększanie jej nie jest dozwolone.

Patrz C ++ 17 8.7 / 4 dotyczący +operatora ( ++ma takie same ograniczenia):

f wyrażenie Pwskazuje na element x[i]obiektu tablicowego xz n elementów, wyrażenia P + Ji J + P(gdzie Jma wartość j) wskazują na element (ewentualnie hipotetyczny), x[i+j]jeśli 0≤i + j≤n; w przeciwnym razie zachowanie jest niezdefiniowane.

interjay
źródło
2
Więc jedyny przypadek x[i]jest takie samo jak x[i + j]to, gdy oba ii jmają wartość 0?
Rami Jen
8
@RamiYen x[i]to ten sam element, x[i+j]jakby j==0.
interjay
1
Ugh, nienawidzę „strefy zmierzchu” semantyki C ++ ... +1.
einpoklum
4
@ einpoklum-reinstateMonica: Tak naprawdę nie ma strefy zmierzchu. Po prostu C ++ jest spójny nawet w przypadku N = 0. W przypadku tablicy N elementów istnieje N + 1 poprawnych wartości wskaźnika, ponieważ można wskazywać za tablicą. Oznacza to, że możesz zacząć od początku tablicy i zwiększyć wskaźnik N razy, aby dojść do końca.
MSalters
1
@MaximEgorushkin Moja odpowiedź dotyczy tego, na co aktualnie pozwala język. Dyskusja o Tobie, na którą chciałbyś pozwolić, jest nie na temat.
interjay
2

Myślę, że masz już odpowiedź; Jeśli spojrzysz nieco głębiej: Powiedziałeś, że zwiększenie iteratora off-the-end to UB, więc: Ta odpowiedź brzmi: co to jest iterator?

Iterator jest tylko obiektem, który ma wskaźnik i zwiększa, że ​​iterator tak naprawdę zwiększa wskaźnik, który ma. Tak więc w wielu aspektach iterator jest traktowany jako wskaźnik.

int arr [] = {0,1,2,3,4,5,6,7,8,9};

int * p = arr; // p wskazuje na pierwszy element w arr

++ p; // p wskazuje na arr [1]

Tak jak możemy używać iteratorów do przechodzenia przez elementy w wektorze, możemy używać wskaźników do przechodzenia przez elementy w tablicy. Oczywiście, aby to zrobić, musimy uzyskać wskaźniki do pierwszego i jednego za ostatnim elementem. Jak właśnie widzieliśmy, możemy uzyskać wskaźnik do pierwszego elementu, korzystając z samej tablicy lub biorąc adres pierwszego elementu. Możemy uzyskać off-the-end wskaźnik, używając innej specjalnej właściwości tablic. Możemy przyjąć adres nieistniejącego elementu jeden za ostatnim elementem tablicy:

int * e = & arr [10]; // wskaźnik tuż za ostatnim elementem w arr

W tym przypadku użyliśmy operatora indeksu dolnego do zindeksowania nieistniejącego elementu; arr ma dziesięć elementów, więc ostatni element w arr znajduje się na pozycji indeksu 9. Jedyne, co możemy zrobić z tym elementem, to wziąć jego adres, który robimy, aby zainicjować e. Podobnie jak off-the-end iterator (§ 3.4.1, s. 106), wskaźnik off-the-end nie wskazuje na element. W związku z tym nie możemy rezygnować ani zwiększać wskaźnika off-the-end.

Pochodzi z C ++ primer 5 edycja firmy Lipmann.

Więc to UB, nie rób tego.

Kropla deszczu 7
źródło
-4

W najściślejszym sensie nie jest to zachowanie niezdefiniowane, ale zdefiniowane w implementacji. Tak więc, choć niewskazane jest, jeśli planujesz wspierać architekturę spoza głównego nurtu, prawdopodobnie możesz to zrobić.

Standardowy cytat podany przez interjay jest dobry, wskazując UB, ale moim zdaniem jest to drugi najlepszy hit, ponieważ dotyczy arytmetyki wskaźnik-wskaźnik (zabawnie, jeden jest jawnie UB, a drugi nie). W pytaniu znajduje się akapit dotyczący operacji bezpośrednio:

[expr.post.incr] / [expr.pre.incr] Operandem
będzie [...] lub wskaźnik do całkowicie zdefiniowanego typu obiektu.

Och, poczekaj chwilę, całkowicie zdefiniowany typ obiektu? To wszystko? To znaczy, tak naprawdę, wpisać ? Więc w ogóle nie potrzebujesz przedmiotu?
Potrzebne jest sporo czytania, aby znaleźć wskazówkę, że coś w tym miejscu może nie być tak dobrze zdefiniowane. Ponieważ do tej pory brzmi to tak, jakbyś mógł to zrobić bez żadnych ograniczeń.

[basic.compound] 3wypowiada się o tym, jaki typ wskaźnika można mieć, a ponieważ nie ma żadnej z pozostałych trzech, wynik twojej operacji wyraźnie mieści się w 3.4: nieprawidłowy wskaźnik .
Nie oznacza to jednak, że nie możesz mieć nieprawidłowego wskaźnika. Przeciwnie, wymienia niektóre bardzo powszechne, normalne warunki (np. Koniec okresu przechowywania), w których wskaźniki regularnie stają się nieważne. Tak więc najwyraźniej jest to dozwolone. I rzeczywiście:

[basic.stc] 4 Przekierowanie
przez niepoprawną wartość wskaźnika i przekazanie niepoprawnej wartości wskaźnika do funkcji zwolnienia ma niezdefiniowane zachowanie. Każde inne użycie nieprawidłowej wartości wskaźnika ma działanie zdefiniowane w implementacji.

Robimy tam „każdy inny”, więc nie jest to zachowanie nieokreślone, ale zdefiniowane w ramach implementacji, dlatego ogólnie dopuszczalne (chyba że implementacja wyraźnie mówi coś innego).

Niestety, to nie koniec historii. Chociaż wynik netto nie zmienia się odtąd, staje się coraz bardziej mylący, im dłużej będziesz szukać „wskaźnika”:

[basic.compound]
Prawidłowa wartość typu wskaźnika obiektu reprezentuje adres bajtu w pamięci lub wskaźnik zerowy. Jeśli obiekt typu T znajduje się pod adresem A, mówi się, że [...] wskazuje na ten obiekt, niezależnie od tego, w jaki sposób uzyskano wartość .
[Uwaga: Na przykład adres znajdujący się za końcem tablicy będzie uważany za wskazujący na niepowiązany obiekt typu elementu tablicy, który może znajdować się pod tym adresem. [...]].

Czytaj jako: OK, kogo to obchodzi! Tak długo, jak wskazuje wskaźnik gdzieś w pamięci , jestem dobry?

[basic.stc.dynamic.safety] Wartość wskaźnika to bezpiecznie uzyskany wskaźnik [bla bla]

Odczytaj jako: OK, bezpiecznie uzyskany, cokolwiek. Nie wyjaśnia, co to jest, ani nie mówi, że faktycznie go potrzebuję. Bezpiecznie wyprowadzony z cholery. Najwyraźniej nadal mogę mieć niezabezpieczone wskaźniki. Domyślam się, że dereferencjowanie ich prawdopodobnie nie byłoby tak dobrym pomysłem, ale ich posiadanie jest całkowicie dozwolone. Nie mówi inaczej.

Implementacja może mieć zmniejszone bezpieczeństwo wskaźnika, w którym to przypadku ważność wartości wskaźnika nie zależy od tego, czy jest to bezpiecznie uzyskana wartość wskaźnika.

Och, więc to może nie mieć znaczenia, tak jak myślałem. Ale czekaj ... „może nie”? Oznacza to, że może również . Skąd mam wiedzieć?

Alternatywnie implementacja może mieć ścisłe bezpieczeństwo wskaźnika, w którym to przypadku wartość wskaźnika, która nie jest bezpiecznie uzyskaną wartością wskaźnika, jest nieprawidłową wartością wskaźnika, chyba że wskazany pełny obiekt ma dynamiczny czas przechowywania i wcześniej został uznany za osiągalny

Czekaj, więc to możliwe, że muszę wywoływać declare_reachable()każdy wskaźnik? Skąd mam wiedzieć?

Teraz możesz przekonwertować na intptr_t, który jest dobrze zdefiniowany, dając całkowitą reprezentację bezpiecznie uzyskanego wskaźnika. Dla których oczywiście, jako liczba całkowita, jest całkowicie uzasadnione i dobrze zdefiniowane, aby zwiększać ją według własnego uznania.
I tak, możesz przekonwertować intptr_tpowrót na wskaźnik, który jest również dobrze zdefiniowany. Po prostu, nie będąc oryginalną wartością, nie ma już gwarancji, że masz bezpiecznie wyprowadzony wskaźnik (oczywiście). Mimo wszystko, zgodnie z literą standardu, podczas gdy jest definiowany jako implementacja, jest to w 100% uzasadniona czynność:

[expr.reinterpret.cast] 5
Wartość typu całkowego lub typu wyliczenia można jawnie przekonwertować na wskaźnik. Wskaźnik skonwertowany na liczbę całkowitą o wystarczającym [...] rozmiarze i z powrotem na tę samą pierwotną wartość wskaźnika [...]; odwzorowania między wskaźnikami i liczbami całkowitymi są w inny sposób zdefiniowane w implementacji.

Haczyk

Wskaźniki są zwykłymi liczbami całkowitymi, tylko ty używasz ich jako wskaźników. Och, gdyby to tylko prawda!
Niestety, istnieją architektury, w których to wcale nie jest prawdą, a samo wygenerowanie niepoprawnego wskaźnika (nie dereferencjowanie go, po prostu umieszczenie go w rejestrze wskaźnika) spowoduje pułapkę.

To jest podstawa „zdefiniowanej implementacji”. To oraz fakt, że zwiększanie wskaźnika w dowolnym momencie, jak możesz, może oczywiście spowodować przepełnienie, z którym standard nie chce sobie poradzić. Koniec przestrzeni adresowej aplikacji może nie pokrywać się z lokalizacją przepełnienia, a nawet nie wiadomo, czy istnieje coś takiego jak przepełnienie wskaźników dla określonej architektury. Podsumowując, jest to koszmarny bałagan, bez związku z możliwymi korzyściami.

Z drugiej strony radzenie sobie z warunkiem jednego obiektu przeszłości jest łatwe: implementacja musi po prostu upewnić się, że żaden obiekt nie jest nigdy przydzielony, aby zajęty został ostatni bajt w przestrzeni adresowej. Jest to dobrze zdefiniowane, ponieważ jest użyteczne i trywialne do zagwarantowania.

Damon
źródło
1
Twoja logika jest wadliwa. „Więc w ogóle nie potrzebujesz przedmiotu?” błędnie interpretuje standard, koncentrując się na jednej zasadzie. Ta zasada dotyczy czasu kompilacji, niezależnie od tego, czy Twój program jest dobrze sformułowany. Istnieje inna zasada dotycząca czasu wykonywania. Tylko w czasie wykonywania można faktycznie mówić o istnieniu obiektów pod określonym adresem. twój program musi spełniać wszystkie zasady; reguły czasu kompilacji w czasie kompilacji i reguły czasu wykonywania w czasie wykonywania.
MSalters,
5
Masz podobne wady logiczne w przypadku „OK, kogo to obchodzi! Tak długo, jak wskaźnik wskazuje gdzieś w pamięci, jestem dobry?”. Nie. Musisz przestrzegać wszystkich zasad. Trudny język o tym, że „koniec jednej tablicy zaczyna się od drugiej tablicy”, daje po prostu pozwolenie implementacji na ciągłe przydzielanie pamięci; nie musi utrzymywać wolnej przestrzeni między przydziałami. Oznacza to, że kod może mieć tę samą wartość A zarówno na końcu jednego obiektu tablicy, jak i na początku innego.
MSalters,
1
„Pułapka” nie jest czymś, co można opisać poprzez „zdefiniowane wdrożenie”. Zauważ, że interjay znalazło ograniczenie +operatora (z którego ++wypływa), co oznacza, że ​​wskazywanie po „jeden po zakończeniu” jest niezdefiniowane.
Martin Bonner obsługuje Monikę
1
@PeterCordes: Proszę przeczytać basic.stc, akapit 4 . Mówi: „Nieokreślone zachowanie [...] pośrednictwa. Każde inne użycie nieprawidłowej wartości wskaźnika ma zachowanie zdefiniowane w implementacji . Nie mylę ludzi, używając tego terminu w innym znaczeniu. To jest dokładne sformułowanie. To nie jest niezdefiniowane zachowanie.
Damon
2
Jest mało prawdopodobne, że znalazłeś lukę w post-inkrementacji, ale nie zacytowałeś pełnej sekcji o tym, co robi inkrementacja. Nie zamierzam teraz na to patrzeć. Zgodził się, że jeśli taki istnieje, jest niezamierzony. W każdym razie, choć byłoby to miłe, gdyby ISO C ++ zdefiniowało więcej rzeczy dla modeli z płaską pamięcią, @MaximEgorushkin, istnieją inne powody (takie jak zawijanie wskaźnika), aby nie zezwalać na dowolne rzeczy. Zobacz komentarze na temat Czy porównania wskaźników powinny być podpisane czy niepodpisane w 64-bitowej wersji x86?
Peter Cordes,