Czy wskaźnik z właściwym adresem i typem nadal jest prawidłowym wskaźnikiem od C ++ 17?

84

(W nawiązaniu do tego pytania i odpowiedzi .)

Przed standardem C ++ 17 w [basic.compound] / 3 znajdowało się następujące zdanie :

Jeśli obiekt typu T znajduje się pod adresem A, wskaźnik typu cv T *, którego wartością jest adres A, wskazuje na ten obiekt, niezależnie od tego, w jaki sposób wartość została uzyskana.

Ale od czasu C ++ 17 to zdanie zostało usunięte .

Na przykład uważam, że to zdanie sprawiło, że ten przykładowy kod został zdefiniowany, a od C ++ 17 jest to niezdefiniowane zachowanie:

 alignas(int) unsigned char buffer[2*sizeof(int)];
 auto p1=new(buffer) int{};
 auto p2=new(p1+1) int{};
 *(p1+1)=10;

Przed C ++ 17 p1+1przechowuje adres *p2i ma właściwy typ, podobnie *(p1+1)jak wskaźnik *p2. W C ++ 17 p1+1znajduje się wskaźnik poza końcem , więc nie jest wskaźnikiem do obiektu i uważam, że nie można go usunąć.

Czy taka jest interpretacja tej zmiany standardowego prawa, czy też istnieją inne zasady, które rekompensują usunięcie przytoczonego zdania?

Oliv
źródło
Uwaga: istnieją nowe / zaktualizowane zasady dotyczące pochodzenia wskaźnika w [basic.stc.dynamic.safety] i [util.dynamic.safety]
MM
@MM Ma to znaczenie tylko w implementacjach ze ścisłym bezpieczeństwem wskaźnika, który jest pustym zestawem (do błędu eksperymentalnego).
TC
4
Cytowane stwierdzenie nigdy nie było w praktyce prawdziwe. Biorąc pod uwagę int a, b = 0;, nie możesz tego zrobić, *(&a + 1) = 1;nawet jeśli zaznaczyłeś &a + 1 == &b. Jeśli możesz uzyskać prawidłowy wskaźnik do obiektu, po prostu odgadując jego adres, to nawet przechowywanie zmiennych lokalnych w rejestrach staje się problematyczne.
TC
@TC 1) Który kompilator umieszcza zmienną w reg po pobraniu jej adresu? 2) Jak poprawnie odgadnąć adres bez mierzenia go?
curiousguy
@curiousguy Właśnie dlatego zwykłe rzutowanie liczby uzyskanej w inny sposób (np. zgadywanie) na adres, na którym znajduje się obiekt, jest problematyczne: tworzy aliasy tego obiektu, ale kompilator nie jest tego świadomy. Jeśli natomiast weźmiesz adres obiektu, to tak, jak mówisz: kompilator jest ostrzegany i odpowiednio synchronizuje.
Peter - Przywróć Monikę

Odpowiedzi:

45

Czy taka interpretacja tej modyfikacji standardowego prawa, czy też istnieją inne zasady, które rekompensują usunięcie tego cytowanego zdania?

Tak, ta interpretacja jest poprawna. Wskaźnik za końcem nie jest po prostu konwertowany na inną wartość wskaźnika, która wskazuje na ten adres.

Nowy [basic.compound] / 3 mówi:

Każda wartość typu wskaźnika jest jedną z następujących:
(3.1) wskaźnik do obiektu lub funkcji (mówi się, że wskaźnik wskazuje na obiekt lub funkcję) lub
(3.2) wskaźnik znajdujący się poza końcem obiektu ([wyrażenie .add]) lub

Te wzajemnie się wykluczają. p1+1jest wskaźnikiem za końcem, a nie wskaźnikiem do obiektu. p1+1wskazuje na hipotetyczną x[1]tablicę o rozmiarze 1 na p1, a nie na p2. Te dwa obiekty nie są konwertowalne za pomocą wskaźnika.

Mamy też nienormatywną notę:

[Uwaga: wskaźnik znajdujący się poza końcem obiektu ([wyr.add]) nie jest traktowany jako wskazujący na niepowiązany obiekt typu obiektu, który może znajdować się pod tym adresem. […]

co wyjaśnia zamiar.


Jak podkreśla TC w wielu komentarzach ( szczególnie w tym ), jest to naprawdę szczególny przypadek problemu, który pojawia się przy próbie implementacji std::vector- czyli [v.data(), v.data() + v.size())musi to być prawidłowy zakres, ale vectornie tworzy obiektu tablicy, więc tylko zdefiniowana arytmetyka wskaźnika będzie przechodzić od dowolnego obiektu w wektorze do końca jego hipotetycznej tablicy o jednym rozmiarze. Aby uzyskać więcej zasobów, patrz CWG 2182 , ta standardowa dyskusja i dwie wersje artykułu na ten temat: P0593R0 i P0593R1 (konkretnie sekcja 1.3).

Barry
źródło
3
Ten przykład jest w zasadzie specjalnym przypadkiem znanego „ vectorproblemu z wykonalnością”. +1.
TC
2
@Oliv Ogólny przypadek istnieje od C ++ 03. Główną przyczyną jest to, że arytmetyka wskaźnika nie działa zgodnie z oczekiwaniami, ponieważ nie masz obiektu tablicy.
TC
1
@TC Uważałem, że jedyny problem wynikał z ograniczenia arytmetyki wskaźników. Czy to usunięcie zdania nie dodaje nowego problemu? Czy przykładowy kod jest również UB w wersji przed C ++ 17?
Oliv
1
@Oliv Jeśli arytmetyka wskaźnika jest ustalona, p1+1nie tworzyłbyś już wskaźnika poza końcem, a cała dyskusja na temat wskaźników poza końcem jest dyskusyjna. Twój szczególny dwuelementowy przypadek specjalny może nie być UB przed 17, ale też nie jest zbyt interesujący.
TC
5
@TC Czy możesz wskazać mi miejsce, w którym mogę poczytać o tym „problemie z implementacją wektorów”?
SirGuy,
8

W naszym przykładzie *(p1 + 1) = 10;powinno to być UB, ponieważ znajduje się o jeden za końcem tablicy o rozmiarze 1. Ale jesteśmy tutaj w bardzo szczególnym przypadku, ponieważ tablica została dynamicznie skonstruowana w większej tablicy znaków.

Dynamiczne tworzenie obiektów jest opisane w 4.5 Model obiektowy C ++ [intro.object] , §3 wersji roboczej n4659 standardu C ++:

3 Jeśli tworzony jest kompletny obiekt (8.3.4) w pamięci skojarzonej z innym obiektem e typu „tablica N znaków bez znaku” lub typu „tablica N std :: bajt” (21.2.1), tablica ta zapewnia pamięć dla utworzonego obiektu, jeśli:
(3.1) - czas życia e rozpoczął się i nie skończył, oraz
(3.2) - miejsce przechowywania nowego obiektu mieści się w całości w e oraz
(3.3) - nie ma mniejszego obiektu tablicowego, który je spełnia ograniczenia.

3.3 wydaje się raczej niejasny, ale poniższe przykłady wyjaśniają cel:

struct A { unsigned char a[32]; };
struct B { unsigned char b[16]; };
A a;
B *b = new (a.a + 8) B; // a.a provides storage for *b
int *p = new (b->b + 4) int; // b->b provides storage for *p
// a.a does not provide storage for *p (directly),
// but *p is nested within a (see below)

W tym przykładzie buffertablica zapewnia pamięć zarówno dla, jak *p1i *p2.

Poniższe akapity dowodzą, że kompletnym przedmiotem dla obu *p1i *p2jest buffer:

4 Obiekt a jest zagnieżdżony w innym obiekcie b, jeśli:
(4.1) - a jest podobiektem z b, lub
(4.2) - b zapewnia miejsce na a lub
(4.3) - istnieje obiekt c, gdzie a jest zagnieżdżony w c , a c jest zagnieżdżone w b.

5 Dla każdego przedmiotu x istnieje jakiś przedmiot zwany kompletnym przedmiotem x, określony w następujący sposób:
(5.1) - Jeśli x jest kompletnym przedmiotem, to kompletny przedmiot x jest sam.
(5.2) - W przeciwnym razie kompletny przedmiot x jest kompletnym przedmiotem (unikalnego) obiektu, który zawiera x.

Gdy już to ustalimy, drugą istotną częścią szkicu n4659 dla C ++ 17 jest [basic.coumpound] §3 (podkreślenie moje):

3 ... Każda wartość typu wskaźnika jest jedną z następujących:
(3.1) - wskaźnik do obiektu lub funkcji (mówi się, że wskaźnik wskazuje na obiekt lub funkcję), lub
(3.2) - wskaźnik poza koniec obiektu (8.7), lub
(3.3) - wartość wskaźnika pustego (7.11) dla tego typu lub
(3.4) - niepoprawna wartość wskaźnika.

Wartość typu wskaźnika, który jest wskaźnikiem na koniec obiektu lub za nim, reprezentuje adres pierwszego bajtu w pamięci (4.4) zajmowanego przez obiekt lub pierwszego bajtu w pamięci po zakończeniu pamięci zajmowanej przez obiekt odpowiednio. [Uwaga: wskaźnik znajdujący się poza końcem obiektu (8.7) nie jest uważany za wskazujący na niepowiązanyobiekt typu obiektu, który może znajdować się pod tym adresem. Wartość wskaźnika staje się nieważna, gdy pamięć, którą oznacza, osiągnie koniec czasu jej przechowywania; patrz 6.7. —Końcówka] Dla celów arytmetyki wskaźników (8.7) i porównań (8.9, 8.10), wskaźnik znajdujący się za końcem ostatniego elementu tablicy x n elementów jest uważany za równoważny wskaźnikowi do hipotetycznego elementu x [ n]. Reprezentacja wartości typów wskaźników jest zdefiniowana w ramach implementacji. Wskaźniki do typów zgodnych z układem muszą mieć takie same wymagania dotyczące reprezentacji wartości i wyrównania (6.11) ...

Uwaga Wskaźnik za końcem ... nie ma tutaj zastosowania, ponieważ obiekty wskazywane przez p1i p2nie są niepowiązane , ale są zagnieżdżone w tym samym kompletnym obiekcie, więc arytmetyka wskaźników ma sens wewnątrz obiektu, który zapewnia pamięć: p2 - p1jest zdefiniowany i jest (&buffer[sizeof(int)] - buffer]) / sizeof(int)czyli 1.

Więc p1 + 1 jest wskaźnikiem *p2, *(p1 + 1) = 10;ma zdefiniowane zachowanie i ustawia wartość *p2.


Przeczytałem również załącznik C4 dotyczący kompatybilności między C ++ 14 a obecnymi (C ++ 17) standardami. Usunięcie możliwości stosowania arytmetyki wskaźnikowej między obiektami tworzonymi dynamicznie w pojedynczej tablicy znaków byłoby istotną zmianą, którą należałoby przytoczyć tam w IMHO, ponieważ jest to powszechnie stosowana funkcja. Ponieważ nic na ten temat nie istnieje na stronach dotyczących kompatybilności, myślę, że potwierdza to, że nie było intencją normy, aby tego zabronić.

W szczególności pokonałoby to wspólną dynamiczną konstrukcję tablicy obiektów z klasy bez domyślnego konstruktora:

class T {
    ...
    public T(U initialization) {
        ...
    }
};
...
unsigned char *mem = new unsigned char[N * sizeof(T)];
T * arr = reinterpret_cast<T*>(mem); // See the array as an array of N T
for (i=0; i<N; i++) {
    U u(...);
    new(arr + i) T(u);
}

arr może być następnie użyty jako wskaźnik do pierwszego elementu tablicy ...

Serge Ballesta
źródło
Aha, więc świat nie oszalał. +1
StoryTeller - Unslander Monica
@StoryTeller: Też mam nadzieję. W dodatku ani słowa o tym w dziale kompatybilności. Ale wygląda na to, że przeciwna opinia ma tutaj większą reputację ...
Serge Ballesta
2
Chwytasz jedno słowo „niepowiązane” w nienormatywnej notatce i nadajesz mu znaczenie, którego nie może znieść, w sprzeczności z normatywnymi regułami w [expr.add] rządzącymi arytmetyką wskaźników. W załączniku C nie ma nic, ponieważ arytmetyka wskaźnika ogólnego przypadku nigdy nie działała w żadnym standardzie. Nie ma co złamać.
TC
3
@TC: Google bardzo nie pomaga w znajdowaniu jakichkolwiek informacji na temat tego „problemu z implementacją wektorów”, czy możesz pomóc?
Matthieu M.
6
@MatthieuM. Zobacz problem podstawowy 2182 , ten wątek standardowej dyskusji, P0593R0 i P0593R1 (szczególnie sekcja 1.3) . Podstawowy problem polega na tym, że vectornie tworzy (i nie może) tworzyć obiektu tablicy, ale ma interfejs, który pozwala użytkownikowi uzyskać wskaźnik obsługujący arytmetykę wskaźników (która jest zdefiniowana tylko dla wskaźników do obiektów tablicowych).
TC
1

Aby rozwinąć odpowiedzi udzielone tutaj, należy podać przykład tego, co moim zdaniem wyklucza zmienione sformułowanie:

Ostrzeżenie: niezdefiniowane zachowanie

#include <iostream>
int main() {
    int A[1]{7};
    int B[1]{10};
    bool same{(B)==(A+1)};

    std::cout<<B<< ' '<< A <<' '<<sizeof(*A)<<'\n';
    std::cout<<(same?"same":"not same")<<'\n';
    std::cout<<*(A+1)<<'\n';//!!!!!  
    return 0;
}

Z powodów całkowicie zależnych od implementacji (i delikatnych) możliwe wyniki tego programu to:

0x7fff1e4f2a64 0x7fff1e4f2a60 4
same
10

To wyjście pokazuje, że dwie tablice (w tym przypadku) są przechowywane w pamięci w taki sposób, że „jedna za końcem” zawiera Awartość adresu pierwszego elementu B.

Zmieniona specyfikacja zapewnia, że ​​niezależnie od A+1nigdy nie jest prawidłowym wskaźnikiem do B. Stara fraza „niezależnie od sposobu uzyskania wartości” mówi, że jeśli „A + 1” wskazuje na „B [0]”, to jest to prawidłowy wskaźnik do „B [0]”. To nie może być dobre i na pewno nie jest to intencja.

Persixty
źródło
Czy to również skutecznie zakazuje używania pustej tablicy na końcu struktury, tak że klasa pochodna lub niestandardowy alokator new mogą określać tablicę o niestandardowym rozmiarze? Być może nowy problem dotyczy kwestii „niezależnie od tego, w jaki sposób” - są sposoby, które są ważne, a niektóre są niebezpieczne?
Gem Taylor,
@Persixty Tak więc wartość obiektu wskaźnika jest określana przez bajty obiektów i nic więcej. Zatem dwa obiekty o tym samym stanie wskazują ten sam obiekt. Jeśli jeden jest ważny, drugi też. Tak więc w przypadku typowych architektur, w których wartość wskaźnika jest reprezentowana jako liczba, dwa wskaźniki o równych wartościach wskazują na te same obiekty, a jeden z końców wskazuje na te same inne obiekty.
curiousguy
@Persixty Ponadto trywialny typ oznacza, że ​​możesz wyliczyć możliwe wartości typu. Zasadniczo każdy nowoczesny kompilator w dowolnym trybie optymalizacji (nawet -O0w niektórych kompilatorach) nie uważa wskaźników za trywialne typy. Kompilatory nie traktują poważnie wymagań standardu, podobnie jak ludzie piszący std, którzy marzą o innym języku i robią wszelkiego rodzaju wynalazki, które są bezpośrednio sprzeczne z podstawowymi zasadami. Oczywiście użytkownicy są zdezorientowani i czasami źle traktowani, gdy narzekają na błędy kompilatora.
curiousguy
Nienormatywna uwaga w tym pytaniu każe nam myśleć o „jednym minionym końcu” jako o niczym nie wskazującym. Oboje wiemy, że w praktyce może to wskazywać na coś, aw praktyce można to wyodrębnić. Ale to (zgodnie ze standardem) nie jest prawidłowym programem. Możemy sobie wyobrazić implementację, która wie, że wskaźnik został uzyskany przez działanie arytmetyczne na koniec i zgłasza wyjątek, jeśli zostanie wyłuskany. Chociaż znam platformę, która to robi. Myślę, że standard nie chce tego wykluczyć.
Persixty,
@curiousguy Również nie jestem pewien, co masz na myśli, wyliczając możliwe wartości. Nie jest to wymagana cecha trywialnego typu zdefiniowanego w C ++.
Persixty,