Uzyskujesz dostęp do nieaktywnego członka związku i niezdefiniowanego zachowania?

128

Miałem wrażenie, że dostęp do unionczłonka innego niż ostatni zestaw to UB, ale nie mogę znaleźć solidnego odniesienia (poza odpowiedziami twierdzącymi, że to UB, ale bez żadnego wsparcia ze strony standardu).

Czy jest to więc niezdefiniowane zachowanie?

Luchian Grigore
źródło
3
C99 (i uważam, że C ++ 11 również) jawnie zezwala na używanie znaków typu punning za pomocą unii. Więc myślę, że jest to zachowanie „zdefiniowane w implementacji”.
Mysticial
1
Używałem go przy kilku okazjach do konwersji z indywidualnego int na char. Tak więc zdecydowanie wiem, że nie jest to nieokreślone. Użyłem go na kompilatorze Sun CC. Może więc nadal zależeć od kompilatora.
go4sri
42
@ go4sri: Najwyraźniej nie wiesz, co to znaczy niezdefiniowanie zachowania. Fakt, że wydawało się, że w jakimś przypadku zadziałało, nie zaprzecza jego nieokreśloności.
Benjamin Lindley,
4
Powiązane: Cel związków w C i C ++
legends2k
4
@Mysticial, post na blogu, do którego odsyłasz, dotyczy konkretnie C99; to pytanie jest oznaczone tylko dla C ++.
davmac

Odpowiedzi:

132

Nieporozumienie polega na tym, że C jawnie zezwala na przebijanie typów za pomocą unii, podczas gdy C ++ () nie ma takiego pozwolenia.

6.5.2.3 Struktura i członkowie związku

95) Jeśli element członkowski używany do odczytywania zawartości obiektu unii nie jest tym samym składnikiem, który był ostatnio używany do przechowywania wartości w obiekcie, odpowiednia część reprezentacji obiektu wartości jest ponownie interpretowana jako reprezentacja obiektu w nowym typ zgodnie z opisem w 6.2.6 (proces czasami nazywany „punningiem typu”). To może być reprezentacja pułapki.

Sytuacja z C ++:

9.5 Związki [class.union]

W unii co najwyżej jeden z niestatycznych elementów członkowskich danych może być aktywny w dowolnym momencie, to znaczy wartość co najwyżej jednego z niestatycznych elementów członkowskich danych może być w dowolnym momencie przechowywana w unii.

C ++ później ma język pozwalający na użycie unii zawierających structs ze wspólnymi sekwencjami początkowymi; nie pozwala to jednak na punting typu.

Aby określić, czy punning typu union jest dozwolony w C ++, musimy szukać dalej. Odwołaj to jest normatywnym odniesieniem dla C ++ 11 (a C99 ma podobny język do C11, co pozwala na używanie unii):

3.9 Typy [basic.types]

4 - Reprezentacja obiektowa obiektu typu T to sekwencja N obiektów typu char bez znaku zajmowanych przez obiekt typu T, gdzie N równa się sizeof (T). Reprezentacja wartości obiektu to zestaw bitów przechowujących wartość typu T. W przypadku typów łatwych do skopiowania reprezentacja wartości to zestaw bitów w reprezentacji obiektu, który określa wartość, która jest jednym dyskretnym elementem implementacji. zdefiniowany zbiór wartości. 42
42) Chodzi o to, aby model pamięci w C ++ był zgodny z modelem z języka programowania C ISO / IEC 9899.

Szczególnie interesująco robi się, kiedy czytamy

3.8 Czas życia obiektu [basic.life]

Okres istnienia obiektu typu T rozpoczyna się, gdy: - uzyskany zostanie magazyn z odpowiednim wyrównaniem i rozmiarem dla typu T oraz - jeśli obiekt ma nietrywialną inicjalizację, jego inicjalizacja jest zakończona.

Tak więc dla typu pierwotnego (który ipso facto ma trywialną inicjalizację) zawartego w unii, czas życia obiektu obejmuje co najmniej czas życia samej unii. To pozwala nam wywołać

3.9.2 Typy złożone [podstawowy. Funt]

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.

Zakładając, że interesująca nas operacja to typ punning, czyli przyjmowanie wartości nieaktywnego członka unii i biorąc pod uwagę powyższe, że mamy prawidłowe odniesienie do obiektu, do którego się odwołuje ten element, operacja ta ma wartość lwartość-do -rvalue konwersja:

4.1 Konwersja Lwartości do rwartości [konw.lwartość]

Wartość gl wartości typu niebędącego funkcją i tablicą Tmoże zostać przekonwertowana na wartość prvalue. Jeśli Tjest niekompletnym typem, program, który wymaga tej konwersji, jest źle sformułowany. Jeśli obiekt, do którego odwołuje się glvalue, nie jest obiektem typu Ti nie jest obiektem typu pochodnego T, lub jeśli obiekt nie jest zainicjowany, program, który wymaga tej konwersji, ma niezdefiniowane zachowanie.

Powstaje zatem pytanie, czy obiekt, który jest nieaktywnym członkiem unii, jest inicjowany przez pamięć do aktywnego członka unii. O ile wiem, tak nie jest, a więc jeśli:

  • suma jest kopiowana do charpamięci tablicowej iz powrotem (3,9: 2) lub
  • Unia jest bajtowo kopiowana do innej unii tego samego typu (3.9: 3) lub
  • do unii uzyskuje się dostęp ponad granicami języka przez element programu zgodny z normą ISO / IEC 9899 (o ile jest to zdefiniowane) (3.9: 4 przypis 42), a następnie

dostęp do unii przez nieaktywnego członka jest zdefiniowany i jest zdefiniowany tak, aby podążał za reprezentacją obiektu i wartości, dostęp bez jednej z powyższych wstawek jest niezdefiniowanym zachowaniem. Ma to konsekwencje dla optymalizacji dozwolonych dla takiego programu, ponieważ implementacja może oczywiście zakładać, że nie występuje niezdefiniowane zachowanie.

Oznacza to, że chociaż możemy legalnie utworzyć wartość l dla nieaktywnego członka związku (dlatego przypisanie do nieaktywnego członka bez konstrukcji jest w porządku), jest on uważany za niezainicjowany.

ecatmur
źródło
5
3.8 / 1 mówi, że czas życia obiektu kończy się, gdy jego pamięć jest ponownie używana. To wskazuje mi, że okres istnienia nieaktywnego członka związku skończył się, ponieważ jego przechowywanie zostało ponownie wykorzystane przez aktywnego członka. Oznaczałoby to, że masz ograniczone możliwości korzystania z członka (3,8 / 6).
bames53
2
Zgodnie z tą interpretacją każdy bit pamięci jednocześnie zawiera obiekty wszystkich typów, które są trywialnie inicjalizowane i mają odpowiednie wyrównanie ... Tak więc czas życia dowolnego nietrywialnie inicjalizowalnego typu natychmiast się kończy, gdy jego pamięć jest ponownie wykorzystywana dla wszystkich tych innych typów ( i nie uruchamiać ponownie, ponieważ nie można ich zainicjować w trywialny sposób)?
bames53
3
Sformułowanie 4.1 jest całkowicie i całkowicie złamane i od tego czasu zostało przepisane. Zabronił wszelkiego rodzaju całkowicie poprawnych rzeczy: zabronił niestandardowych memcpyimplementacji (dostępu do obiektów przy użyciu unsigned charlvalues), zabronił dostępu do *pafter int *p = 0; const int *const *pp = &p;(nawet jeśli niejawna konwersja z int**na const int*const*jest poprawna), zabronił nawet dostępu cpo struct S s; const S &c = s;. Wydanie CWG 616 . Czy nowe sformułowanie na to pozwala? Jest też [basic.lval].
2
@Omnifarious: Miałoby to sens, chociaż wymagałoby również wyjaśnienia (a standard C musi również wyjaśnić, przy okazji), co &oznacza operator jednoargumentowy , gdy stosuje się go do członka związku. Myślę, że wynikowy wskaźnik powinien nadawać się do uzyskania dostępu do elementu członkowskiego przynajmniej do następnego bezpośredniego lub pośredniego użycia dowolnego innego elementu lvalue, ale w gcc wskaźnik nie jest użyteczny nawet tak długo, co rodzi pytanie, co &operator ma znaczyć.
supercat
4
Jedno pytanie dotyczące „Przypomnij sobie, że c99 jest normatywnym odniesieniem dla C ++ 11” Czy to nie ma znaczenia tylko wtedy, gdy standard c ++ wyraźnie odwołuje się do standardu C (np. Dla funkcji biblioteki c)?
MikeMB
28

Standard C ++ 11 mówi to w ten sposób

9.5 Związki

W unii co najwyżej jeden z niestatycznych elementów członkowskich danych może być aktywny w dowolnym momencie, to znaczy wartość co najwyżej jednego z niestatycznych elementów członkowskich danych może być w dowolnym momencie przechowywana w unii.

Jeśli przechowywana jest tylko jedna wartość, jak możesz odczytać inną? Po prostu go tam nie ma.


Dokumentacja gcc wymienia to w sekcji Zachowanie zdefiniowane w implementacji

  • Dostęp do elementu członkowskiego obiektu unii uzyskuje się za pomocą elementu członkowskiego innego typu (C90 6.3.2.3).

Odpowiednie bajty reprezentacji obiektu są traktowane jako obiekt typu używanego do dostępu. Zobacz wpisywanie znaków. Może to być reprezentacja pułapki.

wskazując, że nie jest to wymagane przez standard C.


2016-01-05: Dzięki komentarzom zostałem powiązany z raportem C99 Defect Report # 283, który dodaje podobny tekst jako przypis do standardowego dokumentu C:

78a) Jeśli element członkowski używany do uzyskiwania dostępu do zawartości obiektu unii nie jest tym samym, co element, który był ostatnio używany do przechowywania wartości w obiekcie, odpowiednia część reprezentacji obiektu wartości zostanie ponownie zinterpretowana jako reprezentacja obiektu w nowym typ, jak opisano w 6.2.6 (proces czasami nazywany „punningiem typu”). To może być reprezentacja pułapki.

Nie jestem jednak pewien, czy wiele wyjaśnia, biorąc pod uwagę, że przypis nie jest normatywny dla normy.

Bo Persson
źródło
10
@LuchianGrigore: UB nie jest tym, co standard mówi, że to UB, zamiast tego standard nie opisuje, jak powinien działać. To jest dokładnie taki przypadek. Czy norma opisuje, co się dzieje? Czy jest napisane, że jest zdefiniowana implementacja? Nie i nie. Więc to jest UB. Co więcej, w odniesieniu do argumentu „Członkowie mają ten sam adres pamięci”, będziesz musiał odwołać się do reguł aliasingu, co spowoduje ponowne przeniesienie do UB.
Yakov Galka
5
@Luchian: Jest całkiem jasne, co oznacza aktywny, „to znaczy wartość co najwyżej jednego z niestatycznych elementów danych może być przechowywana w unii w dowolnym momencie”.
Benjamin Lindley,
5
@LuchianGrigore: Tak, są. Istnieje nieskończona liczba przypadków, których norma nie dotyczy (i nie może). (C ++ to kompletna maszyna wirtualna Turinga, więc jest niekompletna.) I co z tego? To wyjaśnia, co oznacza „aktywny”, odwołaj się do powyższego cytatu, po „to jest”.
Yakov Galka
8
@LuchianGrigore: Pominięcie wyraźnej definicji zachowania jest również nieuzasadnionym zachowaniem niezdefiniowanym, zgodnie z sekcją definicji.
jxh
5
@Claudiu To UB z innego powodu - narusza ścisły aliasing.
Mysticial
18

Myślę, że najbliższy standardowi, który mówi, że jego niezdefiniowane zachowanie jest zdefiniowane, jest zachowanie związku zawierającego wspólną sekwencję początkową (C99, §6.5.2.3 / 5):

Wprowadzono jedną specjalną gwarancję w celu uproszczenia korzystania ze związków: jeśli związek zawiera kilka struktur, które mają wspólną sekwencję początkową (patrz poniżej), i jeśli obiekt unii zawiera obecnie jedną z tych struktur, dozwolone jest sprawdzenie początkowa część dowolnego z nich w dowolnym miejscu, w którym widoczna jest deklaracja pełnego typu związku. Dwie struktury mają wspólną sekwencję początkową, jeśli odpowiadające jej elementy członkowskie mają zgodne typy (i, w przypadku pól bitowych, te same szerokości) dla sekwencji jednego lub więcej początkowych elementów członkowskich.

C ++ 11 daje podobne wymagania / uprawnienia w §9.2 / 19:

Jeśli unia układu standardowego zawiera dwie lub więcej struktur układu standardowego, które mają wspólną sekwencję początkową, i jeśli obiekt unii układu standardowego zawiera obecnie jedną z tych struktur układu standardowego, można sprawdzić wspólną początkową część dowolnego z nich. Dwie struktury układu standardowego mają wspólną sekwencję początkową, jeśli odpowiednie elementy członkowskie mają typy zgodne z układem i żaden element członkowski nie jest polem bitowym lub oba są polami bitowymi o tej samej szerokości dla sekwencji jednego lub więcej początkowych elementów członkowskich.

Chociaż żadne z nich nie stwierdza tego bezpośrednio, oba mają mocną implikację, że „sprawdzanie” (czytanie) członka jest „dozwolone” tylko wtedy, gdy 1) jest (częścią) ostatnio napisanym członkiem lub 2) jest częścią wspólnego inicjału sekwencja.

Nie jest to bezpośrednie stwierdzenie, że robienie czegoś innego jest niezdefiniowanym zachowaniem, ale jest to najbliższe, z czego jestem świadomy.

Jerry Coffin
źródło
Aby było to kompletne, musisz wiedzieć, jakie „typy zgodne z układem” są dla C ++, a „typy zgodne” dla C.
Michael Anderson
2
@MichaelAnderson: Tak i nie. Musisz mieć do czynienia z tymi, gdy / jeśli chcesz mieć pewność, że coś podlega temu wyjątkowi - ale prawdziwe pytanie dotyczy tego, czy coś, co wyraźnie wykracza poza wyjątek, naprawdę daje UB. Myślę, że jest to wystarczająco silnie zasugerowane tutaj, aby wyjaśnić zamiar, ale nie sądzę, aby było to kiedykolwiek bezpośrednio określone.
Jerry Coffin
Ta „wspólna sekwencja początkowa” mogła po prostu uratować 2 lub 3 moje projekty z Kosza Rewrite. Byłem wściekły, kiedy po raz pierwszy przeczytałem o większości zastosowań kalamburów unionjako niezdefiniowanych, ponieważ pewien blog dał mi wrażenie, że to jest w porządku i zbudowałem wokół tego kilka dużych struktur i projektów. Teraz myślę , że mimo wszystko mogę być w porządku, ponieważ moje unions zawierają klasy mające te same typy z przodu
podkreślenie_d
@JerryCoffin, myślę, że sugerowałeś to samo pytanie co ja: co by było, gdyby nasze unionzawierało np. A uint8_ti a class Something { uint8_t myByte; [...] };- przypuszczam, że to zastrzeżenie będzie miało również zastosowanie tutaj, ale jest sformułowane bardzo celowo, aby pozwolić tylko na structs. Na szczęście używam już tych zamiast surowych prymitywów: O
underscore_d
@underscore_d: Standard C przynajmniej w pewnym sensie obejmuje to pytanie: „Wskaźnik do obiektu struktury, odpowiednio przekonwertowanego, wskazuje na jego początkowy element członkowski (lub jeśli ten element jest polem bitowym, to na jednostkę, w której się znajduje) , i wzajemnie."
Jerry Coffin
12

Coś, o czym jeszcze nie wspomniano w dostępnych odpowiedziach, to przypis 37 w paragrafie 21 sekcji 6.2.5:

Należy zauważyć, że typ zagregowany nie obejmuje typu unii, ponieważ obiekt o typie unii może zawierać tylko jednego członka naraz.

Ten wymóg wydaje się jasno oznaczać, że nie wolno pisać w jednym członku, a czytać w innym. W tym przypadku może to być niezdefiniowane zachowanie z powodu braku specyfikacji.

mpu
źródło
Wiele implementacji dokumentuje formaty przechowywania i zasady układu. Taka specyfikacja w wielu przypadkach sugerowałaby, jaki byłby efekt odczytu pamięci jednego typu i pisania jako innego w przypadku braku reguł mówiących, że kompilatory nie muszą faktycznie używać swojego zdefiniowanego formatu przechowywania, z wyjątkiem sytuacji, gdy rzeczy są odczytywane i zapisywane przy użyciu wskaźników typu znakowego.
supercat
-3

Dobrze to wyjaśnię na przykładzie.
załóżmy, że mamy następujący związek:

union A{
   int x;
   short y[2];
};

Dobrze zakładam, że sizeof(int)daje to 4, a to sizeof(short)daje 2.
Kiedy union A a = {10}tak dobrze napiszesz , utwórz nową zmienną typu A i umieść w niej wartość 10.

Twoja pamięć powinna wyglądać tak: (pamiętaj, że wszyscy członkowie związku mają to samo miejsce)

       | x |
       | y [0] | y [1] |
       -----------------------------------------
   a-> | 0000 0000 | 0000 0000 | 0000 0000 | 0000 1010 |
       -----------------------------------------

jak widać, wartość ax to 10, wartość ay 1 to 10, a wartość ay [0] to 0.

teraz, co się stanie, jeśli to zrobię?

a.y[0] = 37;

nasza pamięć będzie wyglądać tak:

       | x |
       | y [0] | y [1] |
       -----------------------------------------
   a-> | 0000 0000 | 0010 0101 | 0000 0000 | 0000 1010 |
       -----------------------------------------

spowoduje to zmianę wartości ax na 2424842 (dziesiętnie).

teraz, jeśli twój związek ma liczbę zmiennoprzecinkową lub podwójną, twoja mapa pamięci będzie raczej bałaganem, ze względu na sposób, w jaki przechowujesz dokładne liczby. więcej informacji można znaleźć tutaj .

elyashiv
źródło
18
:) Nie o to prosiłem. Wiem, co dzieje się wewnętrznie. Wiem, że to działa. Zapytałem, czy to w standardzie.
Luchian Grigore