Dlaczego zachowanie f (i = -1, i = -1) jest niezdefiniowane?

267

Czytałem o naruszeniach kolejności oceny , a oni dają przykład, który mnie zastanawia.

1) Jeśli efekt uboczny na obiekcie skalarnym nie jest sekwencjonowany względem innego efektu ubocznego na tym samym obiekcie skalarnym, zachowanie jest niezdefiniowane.

// snip
f(i = -1, i = -1); // undefined behavior

W tym kontekście ijest obiektem skalarnym , co najwyraźniej oznacza

Typy arytmetyczne (3.9.1), typy wyliczeń, typy wskaźników, wskaźnik do typów elementów (3.9.2), std :: nullptr_t oraz wersje tych typów kwalifikowane przez CV (3.9.3) są wspólnie nazywane typami skalarnymi.

Nie rozumiem, w jaki sposób stwierdzenie jest w tym przypadku niejednoznaczne. Wydaje mi się, że niezależnie od tego, czy pierwszy lub drugi argument jest oceniany jako pierwszy, ikończy się jako -1i oba argumenty również -1.

Czy ktoś może wyjaśnić?


AKTUALIZACJA

Naprawdę doceniam całą dyskusję. Jak dotąd bardzo podoba mi się odpowiedź @ harmic, ponieważ ujawnia pułapki i zawiłości w definiowaniu tego stwierdzenia, pomimo tego, jak prosto na pierwszy rzut oka wygląda. @ acheong87 wskazuje pewne problemy, które pojawiają się podczas korzystania z referencji, ale myślę, że jest to ortogonalne względem aspektu skutków ubocznych tego pytania.


PODSUMOWANIE

Ponieważ na to pytanie zwróciło się wiele uwagi, podsumuję główne punkty / odpowiedzi. Po pierwsze, pozwólcie mi na małą dygresję, aby wskazać, że „dlaczego” może mieć ściśle powiązane, ale subtelnie różne znaczenia, mianowicie „z jakiej przyczyny ”, „z jakiego powodu ” i „w jakim celu ”. Pogrupuję odpowiedzi, według których z tych znaczeń „dlaczego” się odnieśli.

z jakiej przyczyny

Główna odpowiedź tutaj pochodzi od Paula Drapera , a Martin J udzielił podobnej, ale nie tak obszernej odpowiedzi. Odpowiedź Paula Drapera sprowadza się do

Jest to niezdefiniowane zachowanie, ponieważ nie jest zdefiniowane, jakie jest zachowanie.

Odpowiedź jest ogólnie bardzo dobra, jeśli chodzi o wyjaśnienie tego, co mówi standard C ++. Zajmuje się również niektórymi powiązanymi przypadkami UB, takimi jak f(++i, ++i);i f(i=1, i=-1);. W pierwszym z pokrewnych przypadków nie jest jasne, czy pierwszym argumentem powinien być i+1drugi i+2i odwrotnie; po drugie, nie jest jasne, czy ipo wywołaniu funkcji powinna wynosić 1 czy -1. Oba te przypadki są UB, ponieważ podlegają następującej zasadzie:

Jeśli efekt uboczny na obiekcie skalarnym nie ma wpływu na inny efekt uboczny na tym samym obiekcie skalarnym, zachowanie jest niezdefiniowane.

Dlatego też f(i=-1, i=-1)jest UB, ponieważ podlega tej samej zasadzie, mimo że intencja programisty jest (IMHO) oczywista i jednoznaczna.

Paul Draper również wyraźnie stwierdza w swoim wniosku, że

Czy mogło to być określone zachowanie? Tak. Czy to zostało zdefiniowane? Nie.

co prowadzi nas do pytania „z jakiego powodu / celu f(i=-1, i=-1)pozostawiono zachowanie nieokreślone?”

z jakiego powodu / celu

Chociaż w standardzie C ++ występują pewne niedopatrzenia (być może nieostrożne), wiele pominięć jest dobrze uzasadnionych i służy konkretnemu celowi. Chociaż zdaję sobie sprawę z tego, że często celem jest albo „ułatwienie pracy kompilatora-pisarza”, albo „szybszy kod”, interesowało mnie przede wszystkim to, czy istnieje uzasadniony powód pozostawienia f(i=-1, i=-1) UB.

harmic i supercat podają główne odpowiedzi, które stanowią przyczynę UB. Harmic wskazuje, że optymalizujący kompilator, który może rozbić pozornie atomowe operacje przypisywania na wiele instrukcji maszynowych i że może dalej przeplatać te instrukcje dla uzyskania optymalnej prędkości. Może to prowadzić do bardzo zaskakujących rezultatów: iw jego scenariuszu kończy się na -2! W ten sposób harmonia pokazuje, jak przypisanie tej samej wartości zmiennej więcej niż raz może mieć złe skutki, jeśli operacje nie są konsekwentne.

supercat przedstawia pokrótce pułapki związane z próbami f(i=-1, i=-1)zrobienia tego, na co wygląda. Wskazuje, że w niektórych architekturach istnieją twarde ograniczenia dotyczące wielokrotnego zapisu na ten sam adres pamięci. Kompilator może mieć trudności z uchwyceniem tego, jeśli mamy do czynienia z czymś mniej trywialnym niż f(i=-1, i=-1).

davidf dostarcza również przykład instrukcji przeplatania bardzo podobnych do instrukcji harmic.

Chociaż każdy z przykładów Harmika, Superkata i Davida jest nieco wymyślony, razem wzięte stanowią one konkretny powód, dla f(i=-1, i=-1)którego zachowanie powinno być niezdefiniowane.

Zaakceptowałem odpowiedź Harmica, ponieważ najlepiej poradziła sobie ze wszystkimi znaczeniami tego, dlaczego, chociaż odpowiedź Paula Drapera lepiej odnosiła się do części „z jakiej przyczyny”.

inne odpowiedzi

JohnB zwraca uwagę, że jeśli weźmiemy pod uwagę przeciążone operatory przypisania (zamiast zwykłych skalarów), możemy również wpaść w kłopoty.

Nicu Stiurca
źródło
1
Obiekt skalarny jest obiektem typu skalarnego. Patrz 3.9 / 9: „Typy arytmetyczne (3.9.1), typy wyliczeń, typy wskaźników, wskaźnik do typów elementów (3.9.2) std::nullptr_toraz wersje tych typów zakwalifikowane do CV (3.9.3) są wspólnie nazywane typami skalarnymi . „
Rob Kennedy
1
Być może na stronie występuje błąd, a oni naprawdę mieli na myśli f(i-1, i = -1)coś podobnego.
Pan Lister,
Spójrz na to pytanie: stackoverflow.com/a/4177063/71074
Robert S. Barnes
@RobKennedy Thanks. Czy „typy arytmetyczne” obejmują bool?
Nicu Stiurca
1
SchighSchagh Twoja aktualizacja powinna znajdować się w sekcji odpowiedzi.
Grijesh Chauhan

Odpowiedzi:

343

Ponieważ operacje nie są konsekwentne, nie można powiedzieć, że instrukcje wykonujące przypisanie nie mogą być przeplatane. Może to być optymalne, w zależności od architektury procesora. Odnośna strona stwierdza:

Jeśli A nie jest sekwencjonowane przed B, a B nie jest sekwencjonowane przed A, wówczas istnieją dwie możliwości:

  • oceny A i B nie mają konsekwencji: mogą być wykonywane w dowolnej kolejności i mogą się nakładać (w ramach jednego wątku wykonania kompilator może przeplatać instrukcje CPU zawierające A i B)

  • oceny A i B są sekwencjonowane w nieokreślony sposób: mogą być wykonywane w dowolnej kolejności, ale nie mogą się pokrywać: albo A będzie kompletna przed B, albo B będzie kompletna przed A. Kolejność może być odwrotna następnym razem z tym samym wyrażeniem jest oceniany.

To samo w sobie nie wydaje się powodować problemu - zakładając, że wykonywana operacja zapisuje wartość -1 w miejscu pamięci. Ale nie ma też nic do powiedzenia, że ​​kompilator nie może zoptymalizować tego w osobnym zestawie instrukcji, który ma ten sam efekt, ale który mógłby zawieść, gdyby operacja została przeplatana z inną operacją w tej samej lokalizacji pamięci.

Na przykład wyobraź sobie, że bardziej efektywne było wyzerowanie pamięci, a następnie zmniejszenie jej w porównaniu z ładowaniem wartości -1 do. Następnie:

f(i=-1, i=-1)

może stać się:

clear i
clear i
decr i
decr i

Teraz mam -2.

Jest to prawdopodobnie fałszywy przykład, ale jest to możliwe.

harmoniczny
źródło
59
Bardzo fajny przykład tego, jak wyrażenie może faktycznie zrobić coś nieoczekiwanego przy jednoczesnym przestrzeganiu reguł sekwencjonowania. Tak, trochę wymyślony, ale kod też został wycięty, o który pytam w pierwszej kolejności. :)
Nicu Stiurca
10
I nawet jeśli przypisanie jest wykonywane jako operacja atomowa, można wyobrazić sobie architekturę superskalarną, w której oba przypisania są wykonywane jednocześnie, powodując konflikt dostępu do pamięci, który powoduje awarię. Język został zaprojektowany tak, aby autorzy kompilatorów mieli jak najwięcej swobody w korzystaniu z zalet maszyny docelowej.
ach
11
Naprawdę podoba mi się twój przykład tego, jak nawet przypisanie tej samej wartości do tej samej zmiennej w obu parametrach może spowodować nieoczekiwany wynik, ponieważ dwa przypisania nie są konsekwencyjne
Martin J.
1
+ 1e + 6 (ok, +1) do tego stopnia, że ​​skompilowany kod nie zawsze jest tym, czego można się spodziewać. Optymalizatorzy są naprawdę dobrzy w rzucaniu ci tego rodzaju krzywizn, gdy nie przestrzegasz zasad: P
Corey
3
W procesorze Arm obciążenie 32-bitowe może zająć do 4 instrukcji: robi load 8bit immediate and shiftto do 4 razy. Zwykle kompilator wykonuje adresowanie pośrednie, aby pobrać liczbę z tabeli, aby tego uniknąć. (-1 można wykonać w 1 instrukcji, ale można wybrać inny przykład).
ctrl-alt-delor
208

Po pierwsze, „skalar obiekt” oznacza typ niczym int, floatlub wskaźnik (zobacz Co jest skalarne obiektu w C ++? ).


Po drugie, może się to wydawać bardziej oczywiste

f(++i, ++i);

miałby nieokreślone zachowanie. Ale

f(i = -1, i = -1);

jest mniej oczywiste.

Nieco inny przykład:

int i;
f(i = 1, i = -1);
std::cout << i << "\n";

Jakie zadanie miało miejsce „ostatnie” i = 1lub i = -1? Nie jest to zdefiniowane w standardzie. Naprawdę, imogą to być środki 5(patrz odpowiedź Harmica, aby uzyskać całkowicie prawdopodobne wyjaśnienie, w jaki sposób może to mieć miejsce). Lub program może segfault. Lub sformatuj dysk twardy.

Ale teraz pytasz: „Co z moim przykładem? Użyłem tej samej wartości ( -1) dla obu zadań. Co może być niejasne?”

Masz rację ... oprócz tego, jak opisał to komitet standardów C ++.

Jeśli efekt uboczny na obiekcie skalarnym nie ma wpływu na inny efekt uboczny na tym samym obiekcie skalarnym, zachowanie jest niezdefiniowane.

Oni mogli dokonały specjalny wyjątek dla szczególnego przypadku, ale nie zrobili tego. (A dlaczego mieliby? Jaki użytek miałby to kiedykolwiek mieć?) Tak więc inadal może być 5. Lub twój dysk twardy może być pusty. Zatem odpowiedź na twoje pytanie brzmi:

Jest to niezdefiniowane zachowanie, ponieważ nie jest zdefiniowane, jakie jest zachowanie.

(To zasługuje na podkreślenie, ponieważ wielu programistów uważa, że ​​„niezdefiniowany” oznacza „losowy” lub „nieprzewidywalny”. Nie robi tego; nie oznacza to, że nie jest zdefiniowany w standardzie. Zachowanie może być w 100% spójne i nadal być niezdefiniowane.)

Czy mogło to być określone zachowanie? Tak. Czy to zostało zdefiniowane? Nie. Dlatego jest „niezdefiniowany”.

To powiedziawszy, „niezdefiniowany” nie oznacza, że ​​kompilator sformatuje dysk twardy ... oznacza, że mógłby i nadal będzie kompilatorem zgodnym ze standardami. Realistycznie jestem pewien, że g ++, Clang i MSVC zrobią to, czego się spodziewałeś. Po prostu nie musieliby „musieć”.


Innym pytaniem może być: dlaczego komitet normalizacyjny C ++ postanowił, aby ten efekt uboczny nie był skutkiem? . Ta odpowiedź będzie obejmować historię i opinie komitetu. Lub Co jest dobrego w tym, że ten efekt uboczny nie występuje w C ++? , co pozwala na jakiekolwiek uzasadnienie, niezależnie od tego, czy było to rzeczywiste uzasadnienie komitetu normalizacyjnego. Możesz zadać te pytania tutaj lub na stronie programmers.stackexchange.com.

Paul Draper
źródło
9
@ hvd, tak, w rzeczywistości wiem, że jeśli włączysz -Wsequence-pointg ++, to cię ostrzeże.
Paul Draper,
47
„Jestem pewien, że g ++, Clang i MSVC zrobią wszystko, czego oczekiwałeś” Nie ufałbym nowoczesnemu kompilatorowi. Oni są źli. Na przykład mogą rozpoznać, że jest to niezdefiniowane zachowanie i założyć, że ten kod jest nieosiągalny. Jeśli nie zrobią tego dzisiaj, mogą to zrobić jutro. Każdy UB jest tykającą bombą zegarową.
CodesInChaos
8
@BlacklightShining „Twoja odpowiedź jest zła, ponieważ nie jest dobra”, nie jest bardzo przydatną informacją zwrotną, prawda?
Vincent van der Weele
13
@BobJarvis Kompilacje absolutnie nie mają obowiązku generowania nawet zdalnego poprawnego kodu w obliczu nieokreślonego zachowania. Może nawet założyć, że ten kod nigdy nie jest nawet wywoływany, a tym samym zastąpić całość znakiem nop (zauważ, że kompilatory faktycznie przyjmują takie założenia w obliczu UB). Dlatego powiedziałbym, że poprawną reakcję na taki raport o błędzie można jedynie „zamknąć, działa zgodnie z przeznaczeniem”
Grizzly,
7
@SchighSchagh Czasami ludzie potrzebują przeformułowania terminów (które tylko na pozór wydają się odpowiedzią tautologiczną). Większość ludzi nowych specyfikacji technicznych zdaniem undefined behaviorśrodki something random will happen, które jest daleko od przypadku, przez większość czasu.
Izkata,
27

Praktyczny powód, aby nie robić wyjątku od reguł tylko dlatego, że dwie wartości są takie same:

// config.h
#define VALUEA  1

// defaults.h
#define VALUEB  1

// prog.cpp
f(i = VALUEA, i = VALUEB);

Rozważ przypadek, w którym było to dozwolone.

Kilka miesięcy później pojawia się potrzeba zmiany

 #define VALUEB 2

Pozornie nieszkodliwy, prawda? A jednak nagle prog.cpp już się nie kompiluje. Uważamy jednak, że kompilacja nie powinna zależeć od wartości literału.

Konkluzja: nie ma wyjątku od reguły, ponieważ zależałoby to od kompilacji od wartości (raczej typu) stałej.

EDYTOWAĆ

@HeartWare wskazał, że stałe wyrażenia formularza A DIV Bnie są dozwolone w niektórych językach, gdy Bwynosi 0, i powodują niepowodzenie kompilacji. Dlatego zmiana stałej może powodować błędy kompilacji w innym miejscu. Co jest, IMHO, niefortunne. Ale z pewnością dobrze jest ograniczyć takie rzeczy do nieuniknionego.

Ingo
źródło
Jasne, ale przykład robi INTEGER literałów. Twój f(i = VALUEA, i = VALUEB);ma zdecydowanie potencjał do nieokreślonego zachowania. Mam nadzieję, że tak naprawdę nie kodujesz w oparciu o wartości kryjące się za identyfikatorami.
Wolf
3
@Wold Ale kompilator nie widzi makr preprocesora. I nawet gdyby tak nie było, trudno znaleźć przykład w jakimkolwiek języku programowania, w którym kod źródłowy kompiluje się, dopóki nie zmieni się stałej int z 1 na 2. Jest to po prostu niedopuszczalne i niewytłumaczalne, podczas gdy można zobaczyć tutaj bardzo dobre wyjaśnienia dlaczego ten kod jest uszkodzony nawet z tymi samymi wartościami.
Ingo
Tak, kompilacje nie widzą makr. Ale czy to było pytanie?
Wilk
1
Twoja odpowiedź nie ma sensu, przeczytaj odpowiedź Harmica i komentarz OP na ten temat.
Wolf
1
Mógłby zrobić SomeProcedure(A, B, B DIV (2-A)). W każdym razie, jeśli język mówi, że CONST musi zostać w pełni oceniony w czasie kompilacji, to oczywiście moje twierdzenie nie jest ważne w tej sprawie. Ponieważ w jakiś sposób zaciera to rozróżnienie czasu kompilacji i czasu wykonywania. Czy zauważyłby również, jeśli piszemy CONST C = X(2-A); FUNCTION X:INTEGER(CONST Y:INTEGER) = B/Y; ? Czy funkcje nie są dozwolone?
Ingo
12

Zamieszanie polega na tym, że przechowywanie stałej wartości w zmiennej lokalnej nie jest jedną instrukcją atomową w każdej architekturze, dla której zaprojektowano C. W tym przypadku procesor, na którym działa kod, ma większe znaczenie niż kompilator. Na przykład na ARM, gdzie każda instrukcja nie może przenosić pełnej stałej 32-bitowej, przechowywanie wartości int w zmiennej wymaga więcej niż jednej instrukcji. Przykład z tym pseudo kodem, w którym można przechowywać tylko 8 bitów na raz i musi działać w rejestrze 32-bitowym, i jest int32:

reg = 0xFF; // first instruction
reg |= 0xFF00; // second
reg |= 0xFF0000; // third
reg |= 0xFF000000; // fourth
i = reg; // last

Możesz sobie wyobrazić, że jeśli kompilator chce zoptymalizować, może przeplatać dwukrotnie tę samą sekwencję i nie wiesz, jaką wartość zostanie zapisana do i; i powiedzmy, że nie jest zbyt mądry:

reg = 0xFF;
reg |= 0xFF00;
reg |= 0xFF0000;
reg = 0xFF;
reg |= 0xFF000000;
i = reg; // writes 0xFF0000FF == -16776961
reg |= 0xFF00;
reg |= 0xFF0000;
reg |= 0xFF000000;
i = reg; // writes 0xFFFFFFFF == -1

Jednak w moich testach gcc jest na tyle miły, że rozpoznaje, że ta sama wartość jest używana dwa razy i generuje ją raz i nie robi nic dziwnego. Dostaję -1, -1 Ale mój przykład jest nadal aktualny, ponieważ ważne jest, aby wziąć pod uwagę, że nawet stała może nie być tak oczywista, jak się wydaje.

davidf
źródło
Podejrzewam, że na ARM kompilator po prostu załaduje stałą z tabeli. To, co opisujesz, bardziej przypomina MIPS.
ach
1
@AndreyChernyakhovskiy Tak, ale w przypadku, gdy nie jest to po prostu -1(że kompilator gdzieś zapisał ), ale jest raczej 3^81 mod 2^32, ale stałe, to kompilator może zrobić dokładnie to, co tutaj zrobiono, a przy pewnej dźwigni omtimizacji przeplatam sekwencje wywołań aby uniknąć czekania.
jo
@tohecz, tak, już to sprawdziłem. Rzeczywiście, kompilator jest zbyt inteligentny, aby załadować każdą stałą z tabeli. W każdym razie nigdy nie użyłby tego samego rejestru do obliczenia dwóch stałych. To z pewnością „nie zdefiniuje” zdefiniowanego zachowania.
ach
@AndreyChernyakhovskiy Ale prawdopodobnie nie jesteś „każdym programistą kompilatorów C ++ na świecie”. Pamiętaj, że istnieją maszyny z 3 krótkimi rejestrami dostępnymi tylko do obliczeń.
jo
@tohecz, rozważ przykład f(i = A, j = B)gdzie ii jsą dwoma oddzielnymi obiektami. Ten przykład nie ma UB. Maszyna mający 3 krótkie rejestrów ma usprawiedliwienia dla kompilatora, aby wymieszać dwie wartości Ai Bw tym samym rejestrze (jak pokazano na odpowiedź użytkownika @ davidf), ponieważ byłoby złamać semantyki programu.
ach
11

Zachowanie jest powszechnie określane jako niezdefiniowane, jeśli istnieje jakiś możliwy powód, dla którego kompilator, który próbował być „pomocny”, mógł zrobić coś, co spowodowałoby całkowicie nieoczekiwane zachowanie.

W przypadku, gdy zmienna jest zapisywana wiele razy, ale nic nie gwarantuje, że zapisy odbywają się w różnych momentach, niektóre rodzaje sprzętu mogą umożliwiać wykonywanie wielu operacji „przechowywania” jednocześnie pod różnymi adresami przy użyciu pamięci z dwoma portami. Jednak niektóre pamięci z dwoma portami wyraźnie zabraniają scenariusza, w którym dwa sklepy trafiają jednocześnie na ten sam adres, niezależnie od tego, czy zapisane wartości są zgodne, czy nie. Jeśli kompilator dla takiej maszyny zauważy dwie nieudane próby zapisu tej samej zmiennej, może albo odmówić kompilacji, albo zapewnić, że dwa zapisy nie będą mogły zostać zaplanowane jednocześnie. Ale jeśli jeden lub oba z nich są dostępne za pośrednictwem wskaźnika lub odwołania, kompilator może nie zawsze być w stanie stwierdzić, czy oba zapisy mogą trafić w to samo miejsce przechowywania. W takim przypadku może zaplanować zapisy jednocześnie, powodując pułapkę sprzętową przy próbie dostępu.

Oczywiście fakt, że ktoś może zaimplementować kompilator C na takiej platformie, nie sugeruje, że takie zachowanie nie powinno być definiowane na platformach sprzętowych przy użyciu magazynów wystarczająco małych, aby można je było przetwarzać atomowo. Próba przechowywania dwóch różnych wartości w sposób niesekwencjonowany może spowodować dziwność, jeśli kompilator nie jest tego świadomy; na przykład biorąc pod uwagę:

uint8_t v;  // Global

void hey(uint8_t *p)
{
  moo(v=5, (*p)=6);
  zoo(v);
  zoo(v);
}

jeśli kompilator wpisuje polecenie „moo” i może powiedzieć, że nie modyfikuje „v”, może zapisać 5 do v, następnie zapisać 6 do * p, a następnie przekazać 5 do „zoo”, a następnie przekazać zawartość v do „zoo”. Jeśli „zoo” nie modyfikuje „v”, nie powinno być mowy, aby oba wywołania miały inne wartości, ale i tak mogłoby się to zdarzyć. Z drugiej strony, w przypadkach, gdy oba sklepy zapisałyby tę samą wartość, taka dziwność nie mogłaby wystąpić i na większości platform nie byłoby rozsądnego powodu, aby implementacja zrobiła coś dziwnego. Niestety, niektórzy autorzy kompilatorów nie potrzebują żadnej wymówki dla głupich zachowań poza „ponieważ Standard na to pozwala”, więc nawet te przypadki nie są bezpieczne.

supercat
źródło
9

Fakt, że wynik byłby taki sam w większości wdrożeń w tym przypadku jest przypadkowe; kolejność oceny jest wciąż nieokreślona. Zastanów się f(i = -1, i = -2): tutaj zamówienie ma znaczenie. Jedynym powodem, dla którego nie ma znaczenia w twoim przykładzie, jest wypadek, że obie wartości są -1.

Biorąc pod uwagę, że wyrażenie jest określone jako niezdefiniowane zachowanie, złośliwie zgodny kompilator może wyświetlać nieodpowiedni obraz podczas oceny f(i = -1, i = -1)i przerywania wykonania - i nadal być uważany za całkowicie poprawny. Na szczęście nie znam takich kompilatorów.

Amadan
źródło
8

Wydaje mi się, że jedyną regułą dotyczącą sekwencjonowania wyrażenia argumentu funkcji jest tutaj:

3) Podczas wywoływania funkcji (niezależnie od tego, czy funkcja jest wbudowana i czy używana jest jawna składnia wywołania funkcji), każde obliczenie wartości i efekt uboczny związany z dowolnym wyrażeniem argumentu lub wyrażeniem postfiksowym oznaczającym wywoływaną funkcję jest sekwencjonowane przed wykonaniem każdego wyrażenia lub instrukcji w treści wywoływanej funkcji.

To nie definiuje sekwencjonowania między wyrażeniami argumentów, więc w tym przypadku kończymy:

1) Jeśli efekt uboczny na obiekcie skalarnym nie ma wpływu na inny efekt uboczny na tym samym obiekcie skalarnym, zachowanie jest niezdefiniowane.

W praktyce w większości kompilatorów cytowany przykład będzie działał dobrze (w przeciwieństwie do „wymazywania dysku twardego” i innych teoretycznych konsekwencji nieokreślonego zachowania).
Jest to jednak zobowiązanie, ponieważ zależy od konkretnego zachowania kompilatora, nawet jeśli dwie przypisane wartości są takie same. Oczywiście, jeśli spróbujesz przypisać różne wartości, wyniki będą „prawdziwie” niezdefiniowane:

void f(int l, int r) {
    return l < -1;
}
auto b = f(i = -1, i = -2);
if (b) {
    formatDisk();
}
Martin J.
źródło
8

C ++ 17 definiuje bardziej rygorystyczne zasady oceny. W szczególności sekwencjonuje argumenty funkcji (chociaż w nieokreślonej kolejności).

N5659 §4.6:15
Oceny A i B sekwencjonuje się w nieokreślony sposób, gdy sekwencję A sekwencjonuje się przed sekwencją B lub sekwencję B przed sekwencją A , ale nie jest określone, które. [ Uwaga : nieokreślone sekwencyjnie oceny nie mogą się nakładać, ale jedno z nich może zostać wykonane jako pierwsze. - uwaga końcowa ]

N5659 § 8.2.2:5
Inicjalizacja parametru, w tym każde powiązane obliczenie wartości i efekt uboczny, jest nieokreślona sekwencyjnie w stosunku do dowolnego innego parametru.

Pozwala na niektóre przypadki, które wcześniej byłyby UB:

f(i = -1, i = -1); // value of i is -1
f(i = -1, i = -2); // value of i is either -1 or -2, but not specified which one
AlexD
źródło
2
Dziękujemy za dodanie tej aktualizacji dla c ++ 17 , więc nie musiałem. ;)
Yakk - Adam Nevraumont
Wspaniale, wielkie dzięki za tę odpowiedź. Nieznaczne uzupełnienie: gdyby fpodpis był f(int a, int b), czy C ++ 17 gwarantuje, że a == -1i b == -2jeśli zostanie wywołany jak w drugim przypadku?
Nicu Stiurca
Tak. Jeśli mamy parametrów aa b, wówczas i-then- asą inicjowane -1, potem i-then- bsą inicjowane -2, lub odwrotnie. W obu przypadkach kończymy na a == -1i b == -2. Przynajmniej tak czytam: „ Inicjalizacja parametru, w tym każde powiązane obliczenie wartości i efekt uboczny, jest nieokreślona sekwencyjnie w stosunku do dowolnego innego parametru ”.
AlexD
Myślę, że tak było w C od zawsze.
fuz
5

Operator przypisania może być przeciążony, w takim przypadku kolejność może mieć znaczenie:

struct A {
    bool first;
    A () : first (false) {
    }
    const A & operator = (int i) {
        first = !first;
        return * this;
    }
};

void f (A a1, A a2) {
    // ...
}


// ...
A i;
f (i = -1, i = -1);   // the argument evaluated first has ax.first == true
JohnB
źródło
1
To prawda, ale pytanie dotyczyło typów skalarnych , które inni wskazywali, oznacza w zasadzie int rodzinę, rodzinę zmiennoprzecinkową i wskaźniki.
Nicu Stiurca,
Prawdziwym problemem w tym przypadku jest to, że operator przypisania jest stanowy, więc nawet regularne manipulowanie zmienną jest podatne na takie problemy.
AJMansfield
2

Odpowiada to po prostu: „Nie jestem pewien, co„ obiekt skalarny ”może oznaczać oprócz czegoś takiego jak int lub float”.

Zinterpretowałbym „obiekt skalarny” jako skrót od „obiektu typu skalarnego” lub po prostu „zmiennej typu skalarnego”. Następnie pointer, enum(stały) są skalarne typu.

To jest artykuł MSDN o typach skalarnych .

Peng Zhang
źródło
Brzmi to trochę jak „odpowiedź tylko link”. Czy możesz skopiować odpowiednie bity z tego linku do tej odpowiedzi (w cytacie blokowym)?
Cole Johnson
1
@ColeJohnson To nie jest tylko odpowiedź na link. Link służy wyłącznie dalszemu wyjaśnieniu. Moja odpowiedź to „wskaźnik”, „wyliczenie”.
Peng Zhang
Nie powiedziałem, że twoja odpowiedź była odpowiedzią tylko na link. Powiedziałem, że „czyta jak [jeden]” . Sugeruję przeczytanie, dlaczego nie chcemy linkować tylko odpowiedzi w sekcji pomocy. Powodem jest to, że jeśli Microsoft zaktualizuje swoje adresy URL w swojej witrynie, link się zepsuje.
Cole Johnson
1

W rzeczywistości istnieje powód, aby nie zależeć od faktu, że kompilator sprawdzi, że iprzypisano mu tę samą wartość dwa razy, aby możliwe było zastąpienie go pojedynczym przypisaniem. Co jeśli mamy jakieś wyrażenia?

void g(int a, int b, int c, int n) {
    int i;
    // hey, compiler has to prove Fermat's theorem now!
    f(i = 1, i = (ipow(a, n) + ipow(b, n) == ipow(c, n)));
}
polkovnikov.ph
źródło
1
Nie trzeba udowodnić twierdzenie Fermata: wystarczy przypisać 1do i. Albo oba argumenty przypisują 1i robi to „właściwą” rzecz, albo argumenty przypisują różne wartości i jest to nieokreślone zachowanie, więc nasz wybór jest nadal dozwolony.