W praktyce, dlaczego różne kompilatory miałyby obliczać różne wartości int x = ++ i + ++ i ;?

165

Rozważ ten kod:

int i = 1;
int x = ++i + ++i;

Mamy pewne przypuszczenia, co kompilator mógłby zrobić dla tego kodu, zakładając, że się kompiluje.

  1. oba ++izwracają 2, w wyniku czego x=4.
  2. jeden ++iwraca, 2a drugi wraca 3, w wyniku czego x=5.
  3. oba ++izwracają 3, w wyniku czego x=6.

Dla mnie druga wydaje się najbardziej prawdopodobna. Wykonywany jest jeden z dwóch ++operatorów i = 1, a ijest zwiększany i 2zwracany jest wynik . Następnie ++wykonywany jest drugi operator with i = 2, ijest zwiększany i 3zwracany jest wynik . Następnie 2i 3są dodawane razem, aby dać 5.

Jednak uruchomiłem ten kod w Visual Studio i wynik był 6. Próbuję lepiej zrozumieć kompilatory i zastanawiam się, co może doprowadzić do wyniku 6. Domyślam się tylko, że kod mógłby zostać wykonany z pewną "wbudowaną" współbieżnością. ++Zostały wywołane dwa operatory, każdy z nich został zwiększony, izanim drugi zwrócił, a następnie oba zwrócone 3. Byłoby to sprzeczne z moim rozumieniem stosu wywołań i musiałoby zostać wyjaśnione.

Jakie (rozsądne) rzeczy mógłby C++zrobić kompilator, co doprowadziłoby do wyniku 4lub wyniku lub 6?

Uwaga

Ten przykład pojawił się jako przykład niezdefiniowanego zachowania w książce Bjarne Stroustrup Programming: Principles and Practice using C ++ (C ++ 14).

Zobacz komentarz cynamonu .

cynamon
źródło
5
Specyfikacja C w rzeczywistości nie obejmuje kolejności operacji lub ocen po prawej stronie = w porównaniu z operacjami pre / postincrement, tylko po lewej stronie.
Cristobol Polychronopolis
2
Jeśli masz ten przykład z książki Stroustrupa, zalecaj cytowanie w pytaniu (jak wspomniano w komentarzu do jednej z odpowiedzi).
Daniel R. Collins
4
@philipxy Twój sugerowany duplikat nie jest duplikatem tego pytania. Pytania są różne. Odpowiedzi w sugerowanym duplikacie nie odpowiadają na to pytanie. Odpowiedzi w sugerowanym duplikacie nie są duplikatami odpowiedzi zaakceptowanych (lub z dużą liczbą głosów) na to pytanie. Myślę, że źle odczytałeś moje pytanie. Proponuję, abyś go ponownie przeczytał i ponownie rozważył głosowanie nad zamknięciem.
cynamon
3
@philipxy „Odpowiedzi mówią, że kompilator może zrobić wszystko…” To nie odpowiada na moje pytanie. „pokazują, że nawet jeśli myślisz, że Twoje pytanie jest inne, jest to tylko jego wariacja”. Co? „chociaż nie podajesz swojej wersji C ++” Moja wersja C ++ nie ma związku z moim pytaniem. „dlatego cały program, w którym znajduje się instrukcja, może zrobić wszystko” Wiem, ale moje pytanie dotyczyło określonego zachowania. „Twój komentarz nie odzwierciedla treści zawartych tam odpowiedzi”. Mój komentarz odzwierciedla treść mojego pytania, które należy ponownie przeczytać.
cynamon
2
Aby odpowiedzieć na tytuł; ponieważ UB oznacza, że ​​bahavioir jest nieokreślony. Wiele kompilatorów wykonanych w różnych okresach historii, przez różnych ludzi dla różnych architektur, poproszonych o pokolorowanie poza liniami i wykonanie rzeczywistej implementacji, musieli umieścić coś w tej części poza specyfikacją, więc ludzie zrobili dokładnie to i każdy z nich użyło różnych kredek. Stąd stara maksyma, nie polegaj na UB
Toby

Odpowiedzi:

200

Kompilator pobiera kod, dzieli go na bardzo proste instrukcje, a następnie ponownie łączy i układa je w sposób, który wydaje mu się optymalny.

Kod

int i = 1;
int x = ++i + ++i;

składa się z następujących instrukcji:

1. store 1 in i
2. read i as tmp1
3. add 1 to tmp1
4. store tmp1 in i
5. read i as tmp2
6. read i as tmp3
7. add 1 to tmp3
8. store tmp3 in i
9. read i as tmp4
10. add tmp2 and tmp4, as tmp5
11. store tmp5 in x

Ale pomimo tego, że jest to lista numerowana w sposób, w jaki ją napisałem, istnieje tylko kilka zależności w kolejności : 1-> 2-> 3-> 4-> 5-> 10-> 11 i 1-> 6-> 7- > 8-> 9-> 10-> 11 muszą pozostać we względnej kolejności. Poza tym kompilator może dowolnie zmieniać kolejność i być może eliminować nadmiarowość.

Na przykład możesz uporządkować listę w ten sposób:

1. store 1 in i
2. read i as tmp1
6. read i as tmp3
3. add 1 to tmp1
7. add 1 to tmp3
4. store tmp1 in i
8. store tmp3 in i
5. read i as tmp2
9. read i as tmp4
10. add tmp2 and tmp4, as tmp5
11. store tmp5 in x

Dlaczego kompilator może to zrobić? Ponieważ nie ma sekwencjonowania skutków ubocznych przyrostu. Ale teraz kompilator może uprościć: na przykład w 4 znajduje się martwy magazyn: wartość jest natychmiast zastępowana. Ponadto tmp2 i tmp4 to tak naprawdę to samo.

1. store 1 in i
2. read i as tmp1
6. read i as tmp3
3. add 1 to tmp1
7. add 1 to tmp3
8. store tmp3 in i
5. read i as tmp2
10. add tmp2 and tmp2, as tmp5
11. store tmp5 in x

A teraz wszystko, co ma związek z tmp1, to martwy kod: nigdy nie jest używany. Ponowne przeczytanie i można również wyeliminować:

1. store 1 in i
6. read i as tmp3
7. add 1 to tmp3
8. store tmp3 in i
10. add tmp3 and tmp3, as tmp5
11. store tmp5 in x

Spójrz, ten kod jest znacznie krótszy. Optymalizator jest zadowolony. Programista nie jest, ponieważ zostałem zwiększony tylko raz. Ups.

Spójrzmy na coś innego, co może zrobić kompilator: wróćmy do oryginalnej wersji.

1. store 1 in i
2. read i as tmp1
3. add 1 to tmp1
4. store tmp1 in i
5. read i as tmp2
6. read i as tmp3
7. add 1 to tmp3
8. store tmp3 in i
9. read i as tmp4
10. add tmp2 and tmp4, as tmp5
11. store tmp5 in x

Kompilator może zmienić kolejność w następujący sposób:

1. store 1 in i
2. read i as tmp1
3. add 1 to tmp1
4. store tmp1 in i
6. read i as tmp3
7. add 1 to tmp3
8. store tmp3 in i
5. read i as tmp2
9. read i as tmp4
10. add tmp2 and tmp4, as tmp5
11. store tmp5 in x

a potem jeszcze raz zauważ, że czytano i dwukrotnie, więc wyeliminuj jedną z nich:

1. store 1 in i
2. read i as tmp1
3. add 1 to tmp1
4. store tmp1 in i
6. read i as tmp3
7. add 1 to tmp3
8. store tmp3 in i
5. read i as tmp2
10. add tmp2 and tmp2, as tmp5
11. store tmp5 in x

To miłe, ale może pójść dalej: może ponownie użyć tmp1:

1. store 1 in i
2. read i as tmp1
3. add 1 to tmp1
4. store tmp1 in i
6. read i as tmp1
7. add 1 to tmp1
8. store tmp1 in i
5. read i as tmp2
10. add tmp2 and tmp2, as tmp5
11. store tmp5 in x

Wtedy może wyeliminować ponowne odczytanie i w 6:

1. store 1 in i
2. read i as tmp1
3. add 1 to tmp1
4. store tmp1 in i
7. add 1 to tmp1
8. store tmp1 in i
5. read i as tmp2
10. add tmp2 and tmp2, as tmp5
11. store tmp5 in x

Teraz 4 to martwy sklep:

1. store 1 in i
2. read i as tmp1
3. add 1 to tmp1
7. add 1 to tmp1
8. store tmp1 in i
5. read i as tmp2
10. add tmp2 and tmp2, as tmp5
11. store tmp5 in x

a teraz 3 i 7 można połączyć w jedną instrukcję:

1. store 1 in i
2. read i as tmp1
3+7. add 2 to tmp1
8. store tmp1 in i
5. read i as tmp2
10. add tmp2 and tmp2, as tmp5
11. store tmp5 in x

Wyeliminuj ostatnie tymczasowe:

1. store 1 in i
2. read i as tmp1
3+7. add 2 to tmp1
8. store tmp1 in i
10. add tmp1 and tmp1, as tmp5
11. store tmp5 in x

A teraz otrzymujesz wynik, jaki daje Ci Visual C ++.

Zauważ, że w obu ścieżkach optymalizacji ważne zależności kolejności zostały zachowane, o ile instrukcje nie zostały usunięte bez robienia niczego.

Sebastian Redl
źródło
36
Obecnie jest to jedyna odpowiedź, która wspomina o sekwencjonowaniu .
2:00 po południu,
3
-1 Nie sądzę, aby ta odpowiedź była wyjaśniona. Obserwowane wyniki w ogóle nie zależą od żadnych optymalizacji kompilatora (zobacz moją odpowiedź).
Daniel R. Collins
3
Zakłada to operacje odczytu, modyfikacji i zapisu. Niektóre procesory, takie jak wszechobecny x86, mają atomową operację inkrementacji, co jeszcze bardziej komplikuje sytuację.
Mark
6
@philipxy "Standard nie ma nic do powiedzenia na temat kodu obiektowego." Standard nie ma też nic do powiedzenia na temat zachowania tego fragmentu - to UB. Taka jest przesłanka pytania. OP chciał wiedzieć, dlaczego w praktyce kompilatory mogą uzyskiwać różne i dziwne wyniki. Moja odpowiedź nie mówi nawet nic o kodzie obiektowym.
Sebastian Redl,
5
@philipxy Nie rozumiem Twojego sprzeciwu. Jak zauważono, pytanie dotyczy tego, co kompilator mógłby zrobić w obecności UB, a nie standardu C ++. Dlaczego użycie kodu obiektowego miałoby być niewłaściwe podczas badania, jak hipotetyczny kompilator przekształca kod? W rzeczywistości, jak coś innego niż kod obiektowy miałoby znaczenie?
Konrad Rudolph
58

Chociaż jest to UB (jak sugerował OP), poniżej przedstawiono hipotetyczne sposoby, w jakie kompilator może uzyskać 3 wyniki. Wszystkie trzy dałyby ten sam poprawny xwynik, gdyby były użyte z różnymi int i = 1, j = 1;zmiennymi zamiast jednej i tej samej i.

  1. oba ++ i zwracają 2, co daje x = 4.
int i = 1;
int i1 = i, i2 = i;   // i1 = i2 = 1
++i1;                 // i1 = 2
++i2;                 // i2 = 2
int x = i1 + i2;      // x = 4
  1. one ++ i zwraca 2, a drugie zwraca 3, co daje x = 5.
int i = 1;
int i1 = ++i;           // i1 = 2
int i2 = ++i;           // i2 = 3
int x = i1 + i2;        // x = 5
  1. oba ++ i zwracają 3, co daje x = 6.
int i = 1;
int &i1 = i, &i2 = i;
++i1;                   // i = 2
++i2;                   // i = 3
int x = i1 + i2;        // x = 6
dxiv
źródło
2
to lepsza odpowiedź niż to, na co liczyłem, dziękuję.
cynamon
1
W przypadku opcji 1 kompilator mógł zwrócić uwagę na preinkrementację i. Wiedząc, że może się to zdarzyć tylko raz, emituje to tylko raz. W przypadku opcji 2 kod jest tłumaczony na kod maszynowy dosłownie, tak jak mógłby to zrobić projekt klasy kompilatora uczelni. Opcja 3 jest podobna do opcji 1, ale utworzyła dwie kopie preinkrementacji. Musiał użyć wektora, a nie zbioru. :-)
Zan Lynx
@dxiv przepraszam, moja wina, pomieszałem posty
muru
22

Dla mnie druga wydaje się najbardziej prawdopodobna.

Idę na opcję nr 4: Obie ++i miejsce jednocześnie.

Nowsze procesory zmierzają w kierunku kilku interesujących optymalizacji i równoległej oceny kodu, o ile jest to dozwolone, tak jak tutaj, to kolejny sposób, w jaki kompilatory nadal tworzą szybszy kod. Postrzegam jako praktyczną realizację , kompilatory zmierzające w kierunku równoległości.

Z łatwością mogłem zobaczyć stan wyścigu powodujący niedeterministyczne zachowanie lub błąd magistrali z powodu tej samej rywalizacji o pamięć - wszystko dozwolone, ponieważ koder naruszył kontrakt C ++ - stąd UB.

Moje pytanie brzmi: jakie (rozsądne) rzeczy mógłby zrobić kompilator C ++, co doprowadziłoby do wyniku 4, wyniku lub 6?

to dało , ale nie licz na to.

Nie używaj ++i + ++iani nie oczekuj rozsądnych rezultatów.

chux - Przywróć Monikę
źródło
Gdybym mógł zaakceptować zarówno tę odpowiedź, jak i @ dxiv, zrobiłbym to. Dziękuję za odpowiedź.
cynamon
4
@UriRaz: Procesor może nawet nie zauważyć zagrożenia danych w zależności od wyboru kompilatora. Np. Kompilator może przypisać ido dwóch rejestrów, inkrementować oba rejestry i zapisać je z powrotem. Procesor nie ma możliwości rozwiązania tego problemu. Podstawowym problemem jest to, że ani C ++, ani nowoczesne procesory nie są ściśle sekwencyjne. C ++ jawnie ma sekwencjonowanie wydarzyło się przed i zdarza się po, aby domyślnie zezwolić na zdarzenie w tym samym czasie.
MSalters
1
Ale wiemy, że tak nie jest w przypadku OP używającego Visual Studio; większość popularnych ISA, w tym x86 i ARM, jest definiowana w kategoriach w pełni sekwencyjnego modelu wykonywania, w którym jedna instrukcja maszynowa w pełni kończy się przed rozpoczęciem następnej. Superskalar niedziałający musi zachować tę iluzję dla pojedynczego wątku. (Inne wątki czytające pamięć współdzieloną nie mają gwarancji, że zobaczą rzeczy w porządku programu, ale podstawową zasadą OoO exec nie jest przerywanie wykonywania jednowątkowego.)
Peter Cordes,
1
To moja ulubiona odpowiedź, ponieważ jako jedyna wspomina o równoległym wykonywaniu instrukcji na poziomie procesora. Przy okazji, dobrze byłoby wspomnieć w odpowiedzi, że albo z powodu warunków wyścigu wątek procesora zostanie zatrzymany, czekając na odblokowanie muteksu w tej samej lokalizacji pamięci, więc jest to bardzo nieoptymalne w modelu współbieżności. Po drugie - ze względu na ten sam stan wyścigu prawdziwą odpowiedzią może być 4lub 5, - w zależności od modelu / szybkości wykonania wątku procesora, więc jest to UB w sercu.
Agnius Vasiliauskas
1
@AgniusVasiliauskas Być może, ale „W praktyce, dlaczego różne kompilatory obliczałyby różne wartości?” Poszukuje łatwiejszego do zrozumienia, biorąc pod uwagę uproszczone spojrzenie na dzisiejsze procesory. Jednak zakres scenariuszy kompilatorów / procesorów jest znacznie większy niż kilka wymienionych odpowiedzi. Twój przydatny wgląd jest jeszcze inny. IMO, równoległość jest przyszłością, więc ta odpowiedź skupiła się na nich, choć w sposób abstrakcyjny - ponieważ przyszłość wciąż się rozwija. IAC, post stał się popularny, a łatwe do zrozumienia odpowiedzi są najlepiej nagradzane.
chux - Przywróć Monikę
17

Myślę, że prosta i nieskomplikowana interpretacja (bez żadnej oferty optymalizacji kompilatora lub wielowątkowości) byłaby po prostu:

  1. Przyrost i
  2. Przyrost i
  3. Dodaj i+i

Przy idwukrotnym zwiększeniu jego wartość wynosi 3, a po dodaniu suma wynosi 6.

Dla sprawdzenia potraktuj to jako funkcję C ++:

int dblInc ()
{
    int i = 1;
    int x = ++i + ++i;
    return x;   
}

Oto kod asemblera, który otrzymuję z kompilacji tej funkcji, używając starej wersji kompilatora GNU C ++ (win32, wersja gcc 3.4.2 (mingw-special)). Nie ma tu żadnych wymyślnych optymalizacji ani wielowątkowości:

__Z6dblIncv:
    push    ebp
    mov ebp, esp
    sub esp, 8
    mov DWORD PTR [ebp-4], 1
    lea eax, [ebp-4]
    inc DWORD PTR [eax]
    lea eax, [ebp-4]
    inc DWORD PTR [eax]
    mov eax, DWORD PTR [ebp-4]
    add eax, DWORD PTR [ebp-4]
    mov DWORD PTR [ebp-8], eax
    mov eax, DWORD PTR [ebp-8]
    leave
    ret

Zwróć uwagę, że zmienna lokalna iznajduje się na stosie tylko w jednym miejscu: adres [ebp-4]. Ta lokalizacja jest zwiększana dwukrotnie (w wierszach od 5 do 8 funkcji asemblera; w tym pozornie nadmiarowe ładunki tego adresu do eax). Następnie w wierszach od 9 do 10 ta wartość jest ładowana eax, a następnie dodawana eax(to znaczy oblicza prąd i + i). Następnie jest kopiowany do stosu i z powrotem doeax jako wartość zwracana (która oczywiście będzie równa 6).

Warto przyjrzeć się normie C ++ (tutaj, starej: ISO / IEC 14882: 1998 (E)), która mówi o wyrażeniach, sekcja 5.4:

O ile nie zaznaczono, kolejność oceny operandów poszczególnych operatorów i podwyrażeń poszczególnych wyrażeń oraz kolejność, w jakiej występują efekty uboczne, jest nieokreślona.

Z przypisem:

Pierwszeństwo operatorów nie jest bezpośrednio określone, ale można je wyprowadzić ze składni.

W tym miejscu podano dwa przykłady nieokreślonego zachowania, oba obejmujące operator inkrementacji (jednym z nich jest i = ++i + 1:).

Teraz, gdyby ktoś chciał, można: Utworzyć klasę opakowującą liczbę całkowitą (taką jak Java Integer); funkcje przeciążeniowe operator+i operator++zwracają obiekty wartości pośredniej; a tym samym napisz ++iObj + ++iObji spraw, aby zwrócił obiekt zawierający 5. (nie włączyłem tutaj pełnego kodu ze względu na zwięzłość).

Osobiście byłbym zaintrygowany, gdyby istniał przykład dobrze znanego kompilatora, który wykonał zadanie w inny sposób niż sekwencja pokazana powyżej. Wydaje mi się, że najprostszą implementacją byłoby wykonanie dwóch asemblerowych kodów incna typie pierwotnym przed wykonaniem operacji dodawania.

Daniel R. Collins
źródło
2
Operator inkrementacji naprawdę ma bardzo dobrze zdefiniowaną wartość „zwracaną”
edc65
@philipxy: Zmieniłem odpowiedź, aby usunąć fragmenty, wobec których się sprzeciwiałeś. W tym momencie możesz bardziej zgodzić się z odpowiedzią lub nie.
Daniel R. Collins
2
To nie są „dwa przykłady nieokreślonego zachowania”, to są dwa przykłady nieokreślonego zachowania , bardzo innej bestii, wynikającej z innego fragmentu normy. Widzę, że C ++ 98 zwykło mówić „nieokreślony” w tekście przykładu przypisu, co jest sprzeczne z tekstem normatywnym, ale zostało to później naprawione.
Cubbi
@Cubbi: Zarówno tekst, jak i przypis w cytowanej tu normie używają wyrażenia „nieokreślony”, „nieokreślony bezpośrednio” i wydaje się, że pasuje do terminu z sekcji definicji 1.3.13.
Daniel R. Collins
1
@philipxy: Widzę, że powtórzyłeś ten sam komentarz do wielu odpowiedzi tutaj. Wygląda na to, że twoja główna krytyka bardziej dotyczy samego pytania PO, którego zakres nie dotyczy tylko abstrakcyjnego standardu.
Daniel R. Collins
7

Rozsądną rzeczą, jaką może zrobić kompilator, jest Common Subexpression Elimination. Jest to już powszechna optymalizacja w kompilatorach: jeśli podwyrażenie, takie jak (x+1)występuje więcej niż raz w większym wyrażeniu, musi zostać obliczone tylko raz. Npa/(x+1) + b*(x+1)x+1 sub ekspresji może być obliczona tylko raz.

Oczywiście kompilator musi wiedzieć, które wyrażenia podrzędne można w ten sposób zoptymalizować. Dzwonienie rand()dwa razy powinno dać dwie losowe liczby. Z tego powodu wywołania funkcji nieliniowe muszą być wyłączone z CSE. Jak zauważyłeś, nie ma reguły, która mówi, jak należy postępować z dwoma wystąpieniami i++, więc nie ma powodu, aby wyłączać je z CSE.

Wynik może rzeczywiście być int x = ++i + ++i;zoptymalizowany do int __cse = i++; int x = __cse << 1. (CSE, po którym następuje wielokrotne zmniejszenie wytrzymałości)

MSalters
źródło
Standard nie ma nic do powiedzenia na temat kodu obiektowego. Nie jest to uzasadnione ani związane z definicją języka.
philipxy
1
@philipxy: Standard nie ma nic do powiedzenia na temat jakiejkolwiek formy niezdefiniowanego zachowania. Taka jest przesłanka pytania.
MSalters
7

W praktyce wywołujesz niezdefiniowane zachowanie. Wszystko może się zdarzyć, nie tylko rzeczy, które uważasz za „rozsądne” i często rzeczy zrobić zdarzyć, że nie biorą pod uwagę uzasadnione. Wszystko jest z definicji „rozsądne”.

Bardzo rozsądną kompilacją jest to, że kompilator zauważa, że ​​wykonanie instrukcji wywoła niezdefiniowane zachowanie, dlatego instrukcja nie może zostać wykonana, dlatego jest tłumaczona na instrukcję, która celowo powoduje zawieszenie aplikacji. To bardzo rozsądne.

Downvoter: GCC zdecydowanie się z tobą nie zgadza.

gnasher729
źródło
Kiedy norma określa coś jako „niezdefiniowane zachowanie”, oznacza to nic więcej ani mniej niż to, że zachowanie to jest poza jurysdykcją normy . Ponieważ norma nie próbuje oceniać racjonalności rzeczy znajdujących się poza jej jurysdykcją i nie usiłuje zakazać wszystkich sposobów, w jakie zgodna implementacja może być bezzasadnie bezużyteczna, brak narzucania przez Normę wymagań w określonej sytuacji nie oznacza żadnego osądu, że wszystkie możliwe działania są równie „rozsądne”.
supercat
6

Nie ma uzasadnione rzeczy, którą kompilator mógłby zrobić, aby uzyskać wynik 6, ale jest to możliwe i uzasadnione. Wynik 4 jest całkowicie rozsądny, a wynik 5 na granicy uważam za rozsądny. Wszystkie są całkowicie legalne.

Hej, czekaj! Czy nie jest jasne, co musi się stać? Dodawanie wymaga wyników dwóch przyrostów, więc oczywiście musi to nastąpić najpierw. Idziemy od lewej do prawej, więc ... argh! Gdyby to było takie proste. Niestety tak nie jest. Mamy nie idź w lewo w prawo, i to jest problem.

Odczytanie lokalizacji pamięci do dwóch rejestrów (lub zainicjowanie ich obu z tego samego literału, optymalizacja podróży w obie strony do pamięci) jest bardzo rozsądną rzeczą dla kompilatora. Skutkuje to w efekcie potajemnie występowaniem dwóch różnych zmiennych, z których każda ma wartość 2, które ostatecznie zostaną dodane do wyniku 4. Jest to „rozsądne”, ponieważ jest szybkie i wydajne oraz zgodne z obydwoma standard i kod.

Podobnie, lokalizacja pamięci może być odczytana raz (lub zmienna zainicjowana z literału) i raz inkrementowana, a kopia w tle w innym rejestrze mogłaby zostać zwiększona po tym, co spowodowałoby dodanie 2 i 3. Jest to, powiedziałbym, rozsądne granice , chociaż całkowicie legalne. Uważam to za rozsądne, ponieważ nie jest to ani jedno, ani drugie. Nie jest to ani „rozsądny” zoptymalizowany sposób, ani też „rozsądny” dokładnie pedantyczny sposób. Jest trochę pośrodku.

Dwukrotne zwiększenie lokalizacji pamięci (w wyniku czego uzyskuje się wartość 3), a następnie dodanie tej wartości do siebie w celu uzyskania wyniku końcowego równego 6 jest uzasadnione, ale nie całkiem rozsądne, ponieważ wykonywanie podróży w obie strony pamięci nie jest dokładnie wydajne. Chociaż na procesorze z dobrym przekazywaniem do magazynu, równie dobrze byłoby to zrobić, ponieważ sklep powinien być w większości niewidoczny ...
Ponieważ kompilator „wie”, że jest to ta sama lokalizacja, równie dobrze może zdecydować się na zwiększenie wartość dwukrotnie w rejestrze, a następnie dodaj ją również do siebie. Każde podejście dałoby wynik 6.

Kompilator może, zgodnie z brzmieniem normy, dać ci taki wynik, chociaż osobiście uważałbym 6 za notatkę "pieprzyć cię" z Wydziału Wstrętnego, ponieważ jest to raczej nieoczekiwana rzecz (legalna lub nie, staranie się, aby zawsze sprawiać jak najmniej niespodzianek, to dobra rzecz!). Chociaż, widząc, jak w grę wchodzi Undefined Behavior, nie można niestety spierać się o „nieoczekiwane”, eh.

Więc właściwie jaki jest kod, który tam masz, dla kompilatora? Zapytajmy clang, który pokaże nam, czy ładnie poprosimy (wywołując -ast-dump -fsyntax-only):

ast.cpp:4:9: warning: multiple unsequenced modifications to 'i' [-Wunsequenced]
int x = ++i + ++i;
        ^     ~~
(some lines omitted)
`-CompoundStmt 0x2b3e628 <line:2:1, line:5:1>
  |-DeclStmt 0x2b3e4b8 <line:3:1, col:10>
  | `-VarDecl 0x2b3e430 <col:1, col:9> col:5 used i 'int' cinit
  |   `-IntegerLiteral 0x2b3e498 <col:9> 'int' 1
  `-DeclStmt 0x2b3e610 <line:4:1, col:18>
    `-VarDecl 0x2b3e4e8 <col:1, col:17> col:5 x 'int' cinit
      `-BinaryOperator 0x2b3e5f0 <col:9, col:17> 'int' '+'
        |-ImplicitCastExpr 0x2b3e5c0 <col:9, col:11> 'int' <LValueToRValue>
        | `-UnaryOperator 0x2b3e570 <col:9, col:11> 'int' lvalue prefix '++'
        |   `-DeclRefExpr 0x2b3e550 <col:11> 'int' lvalue Var 0x2b3e430 'i' 'int'
        `-ImplicitCastExpr 0x2b3e5d8 <col:15, col:17> 'int' <LValueToRValue>
          `-UnaryOperator 0x2b3e5a8 <col:15, col:17> 'int' lvalue prefix '++'
            `-DeclRefExpr 0x2b3e588 <col:17> 'int' lvalue Var 0x2b3e430 'i' 'int'

Jak widać, to samo lvalue Var 0x2b3e430ma prefiks ++zastosowany w dwóch lokalizacjach, a te dwa znajdują się poniżej tego samego węzła w drzewie, co jest bardzo nietypowym operatorem (+), o którym nie mówi się nic specjalnego o sekwencjonowaniu. Dlaczego to jest ważne? Cóż, czytaj dalej.

Zwróć uwagę na ostrzeżenie: „wielokrotne niepisane modyfikacje 'i'” . Och, to nie brzmi dobrze. Co to znaczy? [basic.exec] mówi nam o skutkach ubocznych i sekwencjonowaniu oraz mówi nam (paragraf 10), że domyślnie, o ile wyraźnie nie zaznaczono inaczej, oceny operandów poszczególnych operatorów i podwyrażeń poszczególnych wyrażeń nie są sekwencjonowane . Cóż, cholera, tak jest w przypadku operator+- nic nie jest powiedziane inaczej, więc ...

Ale czy obchodzi nas zsekwencjonowanie przed, nieokreślone, czy nie zsekwencjonowane? Kto w ogóle chce wiedzieć?

Ten sam akapit mówi nam również, że oceny bez kolejności mogą się pokrywać i że kiedy odnoszą się do tej samej lokalizacji pamięci (tak jest!) I nie są potencjalnie współbieżne, to zachowanie jest niezdefiniowane. Tutaj robi się naprawdę brzydko, ponieważ oznacza to, że nic nie wiesz i nie masz żadnych gwarancji, że będziesz „rozsądny”. Nierozsądna rzecz jest w rzeczywistości całkowicie dopuszczalna i „rozsądna”.

Damon
źródło
Użycie „rozsądnego” miało po prostu powstrzymać kogokolwiek przed stwierdzeniem, że „kompilator może zrobić WSZYSTKO, nawet wyemitować pojedynczą instrukcję 'ustaw x na 7.'” Być może powinienem był to wyjaśnić.
cynamon
@cinnamon Wiele lat temu, kiedy byłem młody i niedoświadczony, inżynierowie kompilatorów w firmie Sun powiedzieli mi, że ich kompilator działał w sposób absolutnie rozsądny, tworząc kod dla nieokreślonego zachowania, które wówczas uważałem za nierozsądne. Wyciągnięta lekcja.
gnasher729
Standard nie ma nic do powiedzenia na temat kodu obiektowego. Jest to fragmentaryczne i niejasne, w jaki sposób sugerowane implementacje są uzasadnione lub powiązane z definicją języka.
philipxy
@philipxy: Standard określa, co jest dobrze sformułowane i dobrze zdefiniowane, a co nie. W przypadku tego Q definiuje zachowanie jako niezdefiniowane. Poza legalnością istnieje również rozsądne założenie, że kompilatory generują wydajny kod. Tak, masz rację, standard tego nie wymaga. Niemniej jest to rozsądne założenie.
Damon
@Damon: Standard określa, jakie akcje wszystkie implementacje muszą traktować jako zdefiniowane, a które implementacje nie muszą traktować jako zdefiniowane. Ponieważ niektóre zadania wymagają szerszego zakresu semantyki niż inne, niepowodzenie standardu w zdefiniowaniu zachowania jakiejś akcji nie oznacza, że ​​implementacja, która i tak ją definiuje, nie będzie bardziej odpowiednia dla niektórych zadań niż taka, która nie , ani też brak zdefiniowania takich zachowań nie sprawi, że implementacja będzie mniej odpowiednia do niektórych celów niż inne, które ją definiują.
supercat
1

Obowiązuje zasada :

Pomiędzy poprzednim a następnym punktem sekwencji obiekt skalarny musi mieć swoją przechowywaną wartość zmodyfikowaną co najwyżej raz przez ocenę wyrażenia, w przeciwnym razie zachowanie jest niezdefiniowane.

Zatem nawet x = 100 jest możliwym ważnym wynikiem.

Dla mnie najbardziej logicznym wynikiem w przykładzie jest 6, ponieważ dwukrotnie zwiększamy wartość i, a oni dodają ją do siebie. Trudno jest dodać wartości przed obliczeniami z obu stron „+”.

Jednak programiści kompilatorów mogą zaimplementować dowolną inną logikę.

Slavenskij
źródło
0

Wygląda na to, że ++ i zwraca lwartość, ale i ++ zwraca wartość r.
Więc ten kod jest w porządku:

int i = 1;
++i = 10;
cout << i << endl;

To nie jest:

int i = 1;
i++ = 10;
cout << i << endl;

Powyższe dwie instrukcje są zgodne z VisualC ++, GCC7.1.1, CLang i Embarcadero.
Dlatego twój kod w VisualC ++ i GCC7.1.1 jest podobny do następującego

int i = 1;
... do something there for instance: ++i; ++i; ...
int x = i + i;

Patrząc na demontaż, najpierw inkrementuje i, przepisuje i. Kiedy próbujesz dodać, robi to samo, zwiększa i i przepisuje. Następnie dodaje i do i. Zauważyłem, że CLang i Embarcadero działają inaczej. Czyli nie jest zgodne z pierwszą instrukcją, po pierwszym ++ i zapisuje wynik w wartości r, a następnie dodaje go do drugiego i ++.
wprowadź opis obrazu tutaj

armagedescu
źródło
Problem z „look line an lvalue” polega na tym, że mówisz z perspektywy standardu C ++, a nie kompilatora.
MSalters
@MSalters Oświadczenie jest zgodne z VisualStudio 2019, GCC7.1.1, clang i Embarcadero oraz z pierwszym fragmentem kodu. A więc specyfikacja jest spójna. Ale to działa inaczej w przypadku drugiego fragmentu kodu. Drugi fragment kodu jest zgodny z VisualStudio 2019 i GCC7.1.1, ale nie jest spójny z clang i Embarcadero.
armagedescu
3
Cóż, pierwszy fragment kodu w Twojej odpowiedzi to prawniczy C ++, więc oczywiście implementacje są zgodne ze specyfikacją. W porównaniu z pytaniem, twoje „zrób coś” kończy się średnikiem, co oznacza, że ​​jest to pełne stwierdzenie. To tworzy sekwencjonowanie, które jest wymagane przez standard C ++, ale nie występuje w pytaniu.
MSalters
@MSalters Chciałem to zrobić jako równoważny pseudokod. Jednak nie jestem pewien, jak to przeformułować
armagedescu
0

Osobiście nigdy nie spodziewałbym się, że kompilator w Twoim przykładzie wyświetli 6. Na Twoje pytanie są już dobre i szczegółowe odpowiedzi. Spróbuję krótkiej wersji.

Zasadniczo ++ijest to proces dwuetapowy w tym kontekście:

  1. Zwiększ wartość i
  2. Przeczytaj wartość i

W kontekście ++i + ++idwóch stron dodatek może być oceniany w dowolnej kolejności zgodnie z normą. Oznacza to, że te dwa przyrosty są uważane za niezależne. Nie ma też zależności między tymi dwoma terminami. W związku z tym przyrost i odczyt imogą być przeplatane. To daje potencjalny porządek:

  1. Przyrost idla lewego operandu
  2. Przyrost idla właściwego operandu
  3. Przeczytaj ponownie ilewy operand
  4. Przeczytaj z powrotem idla odpowiedniego operandu
  5. Suma dwóch: daje 6

Teraz, kiedy o tym myślę, 6 ma największy sens według normy. Dla wyniku 4 potrzebujemy CPU, który najpierw odczytuje iniezależnie, a następnie zwiększa i zapisuje wartość z powrotem w tym samym miejscu; w zasadzie stan wyścigu. Dla wartości 5 potrzebujemy kompilatora, który wprowadza tymczasowe.

Ale standard mówi, że ++izwiększa zmienną przed jej zwróceniem, tj. Przed faktycznym wykonaniem bieżącej linii kodu. Operator sum +musi sumować i + ipo zastosowaniu przyrostów. Powiedziałbym, że C ++ musi pracować na zmiennych, a nie na semantyce wartości. Dlatego według mnie 6 ma teraz największy sens, ponieważ opiera się na semantyce języka, a nie na modelu wykonawczym procesorów.

Szymon
źródło
0
#include <stdio.h>


void a1(void)
{
    int i = 1;
    int x = ++i;
    printf("i=%d\n",i);
    printf("x=%d\n",x);
    x = x + ++i;    // Here
    printf("i=%d\n",i);
    printf("x=%d\n",x);
}


void b2(void)
{
    int i = 1;
    int x = ++i;
    printf("i=%d\n",i);
    printf("x=%d\n",x);
    x = i + ++i;    // Here
    printf("i=%d\n",i);
    printf("x=%d\n",x);
}


void main(void)
{
    a1();
    // b2();
}
John Linq
źródło
Witamy w stackoverflow! Czy możesz podać jakieś ograniczenia, założenia lub uproszczenia w swojej odpowiedzi? Więcej informacji na temat odpowiedzi można znaleźć pod tym linkiem: stackoverflow.com/help/how-to-answer
Usama Abdulrehman
0

cóż, zależy to od projektu kompilatora, dlatego odpowiedź będzie zależała od sposobu dekodowania instrukcji przez kompilator. Lepszym wyborem byłoby użycie dwóch różnych zmiennych ++ x i ++ y zamiast tego do stworzenia logiki. uwaga: wynik zależy od najnowszej wersji języka w ms Visual Studio, jeśli zostanie zaktualizowana, więc jeśli zasady uległy zmianie, wynik

sam
źródło
0

Spróbuj tego

int i = 1;
int i1 = i, i2 = i;   // i1 = i2 = 1
++i1;                 // i1 = 2
++i2;                 // i2 = 2
int x = i1 + i2;      // x = 4
MAC27
źródło
0

Z tego linku kolejność oceny :

Kolejność obliczania operandów dowolnego operatora C, w tym kolejność obliczania argumentów funkcji w wyrażeniu wywołania funkcji oraz kolejność obliczania podwyrażeń w dowolnym wyrażeniu jest nieokreślona (z wyjątkiem przypadków wymienionych poniżej). Kompilator oceni je w dowolnej kolejności i może wybrać inną kolejność, gdy to samo wyrażenie zostanie ponownie ocenione .

Z cytatów jasno wynika, że ​​kolejność oceny nie jest określona przez standardy C. Różne kompilatory realizują różne porządki oceny. Kompilator może oceniać takie wyrażenia w dowolnej kolejności. Dlatego różne kompilatory podają różne dane wyjściowe dla wyrażenia wymienionego w pytaniu.

Ale jeśli punkt sekwencji jest obecny między podwyrażeniami Exp1 i Exp2, to zarówno obliczanie wartości, jak i efekty uboczne Exp1 są sekwencjonowane - przed każdym obliczeniem wartości i efektem ubocznym Exp2.

Krishna Kanth Yenumula
źródło
Co ten cytat i twoje oświadczenie ma wspólnego z odpowiedzią na pytanie? (Retorycznie.) W każdym razie podany kod ma niezdefiniowane zachowanie, jak wyjaśniono w innym miejscu tutaj, więc twoje punkty nie mają zastosowania. Pytający próbuje również zapytać (słabo) o to, jakie aspekty ich implementacji prowadzą do tego, co robi, biorąc pod uwagę, że kod jest nieokreślony. Ponadto nie zaakceptują tego bez wyjaśnienia ich pytania, cokolwiek jest „rozsądne” dla kompilatora. Również ten post nic nie dodaje do postów już tutaj.
philipxy
Twój komentarz nie dotyczy żadnego z moich problemów. W tym, że kod jest niezdefiniowany i nie jest nieokreślony.
philipxy
Cytaty nie wspominają o nieokreślonym, wspominają o nieokreślonym. Skończyłem.
philipxy
Tak, nie jest określony, dlatego różne kompilatory wymagają różnych kolejności ocen. Dlatego otrzymasz różne wyniki dla różnych kompilatorów.
Krishna Kanth Yenumula
Daj nam kontynuować tę dyskusję w czacie .
Krishna Kanth Yenumula
-4

W praktyce wywołujesz niezdefiniowane zachowanie. Wszystko może się zdarzyć, nie tylko rzeczy, które uważasz za „rozsądne” i często rzeczy nie zdarzyć, że nie biorą pod uwagę uzasadnione. Wszystko jest z definicji „rozsądne”.

Bardzo rozsądną kompilacją jest to, że kompilator zauważa, że ​​wykonanie instrukcji wywoła niezdefiniowane zachowanie, dlatego instrukcja nie może być nigdy wykonana, dlatego jest tłumaczona na instrukcję, która celowo powoduje zawieszenie aplikacji. To bardzo rozsądne. W końcu kompilator wie, że ta awaria nigdy się nie wydarzy.

gnasher729
źródło
1
Myślę, że źle zrozumiałeś pytanie. Pytanie dotyczy ogólnego lub konkretnego zachowania, które może prowadzić do określonych wyników (wyniki x = 4, 5 lub 6). Jeśli nie podoba ci się moje użycie słowa „rozsądny”, kieruję Cię do mojego komentarza powyżej, na który odpowiedziałeś: „Użycie słowa„ rozsądne ”miało po prostu uniemożliwić komukolwiek powiedzenie„ kompilator może zrobić WSZYSTKO, nawet wyemituj pojedynczą instrukcję '"ustaw x na 7."' "Jeśli masz lepsze sformułowanie pytania, które zachowuje ogólną ideę, jestem na to otwarty. Wygląda na to, że ponownie opublikowałeś swoją odpowiedź.
cynamon
2
zasugeruj usunięcie jednej z twoich dwóch odpowiedzi, ponieważ obie są bardzo podobne
MM