W którym punkcie pętli przepełnienie całkowitoliczbowe staje się niezdefiniowanym zachowaniem?

86

To jest przykład ilustrujący moje pytanie, które dotyczy znacznie bardziej skomplikowanego kodu, którego nie mogę tutaj zamieścić.

#include <stdio.h>
int main()
{
    int a = 0;
    for (int i = 0; i < 3; i++)
    {
        printf("Hello\n");
        a = a + 1000000000;
    }
}

Ten program zawiera niezdefiniowane zachowanie na mojej platformie, ponieważ aprzepełni się w trzeciej pętli.

Czy to powoduje, że cały program zachowuje się niezdefiniowane, czy dopiero po wystąpieniu przepełnienia ? Kompilator mógłby potencjalnie wyszło, że a będzie przelewać więc może zadeklarować całą pętlę niezdefiniowane i nie przeszkadza, aby uruchomić printfs choć wszystko stało przed przelewem?

(Otagowane C i C ++, mimo że są różne, ponieważ byłbym zainteresowany odpowiedziami dla obu języków, jeśli są różne).

jcoder
źródło
7
Ciekawe, czy kompilator mógłby sprawdzić, że anie jest używany (z wyjątkiem samego obliczenia) i po prostu usunąća
4386427
12
Może Ci się spodobać My Little Optimizer: Undefined Behavior is Magic z tegorocznego CppCon. Chodzi o to, jakie optymalizacje mogą przeprowadzić kompilatory w oparciu o niezdefiniowane zachowanie.
TartanLlama,

Odpowiedzi:

108

Jeśli interesuje Cię czysto teoretyczna odpowiedź, standard C ++ zezwala na niezdefiniowane zachowanie na „podróż w czasie”:

[intro.execution]/5: Zgodna implementacja wykonująca dobrze uformowany program będzie dawać takie samo obserwowalne zachowanie, jak jedno z możliwych wykonań odpowiedniej instancji maszyny abstrakcyjnej z tym samym programem i tymi samymi danymi wejściowymi. Jednakże, jeśli jakiekolwiek takie wykonanie zawiera nieokreśloną operację, niniejsza Norma Międzynarodowa nie nakłada żadnych wymagań na implementację wykonującą ten program z tym wejściem (nawet w odniesieniu do operacji poprzedzających pierwszą niezdefiniowaną operację)

W związku z tym, jeśli twój program zawiera niezdefiniowane zachowanie, to zachowanie całego programu jest niezdefiniowane.

TartanLlama
źródło
4
@KeithThompson: Ale sneeze()sama funkcja jest niezdefiniowana w żadnej klasie Demon(której podklasą jest odmiana nosowa), co i tak sprawia, że ​​całość jest okrągła.
Sebastian Lenartowicz
1
Ale printf może nie powrócić, więc pierwsze dwie rundy są zdefiniowane, ponieważ dopóki nie skończysz, nie jest jasne, czy kiedykolwiek będzie UB. Zobacz stackoverflow.com/questions/23153445/…
usr
1
Dlatego też kompilator ma techniczne prawa do emitowania „nop” dla jądra Linuksa (ponieważ kod bootstrap opiera się na niezdefiniowanym zachowaniu): blog.regehr.org/archives/761
Crashworks
3
@Crashworks I właśnie dlatego Linux jest napisany i skompilowany jako nieporęczne C. (tj. Nadzbiór C, który wymaga konkretnego kompilatora z określonymi opcjami, takimi jak -fno-strict-aliasing)
user253751
3
@usr Spodziewam się, że jest zdefiniowane, jeśli printfnie zwraca, ale jeśli printfma powrócić, to niezdefiniowane zachowanie może powodować problemy przed printfwywołaniem. Stąd podróże w czasie. printf("Hello\n");a następnie następna linia kompiluje się jakoundoPrintf(); launchNuclearMissiles();
user253751
31

Najpierw poprawię tytuł tego pytania:

Niezdefiniowane zachowanie nie należy (konkretnie) do dziedziny wykonania.

Niezdefiniowane zachowanie wpływa na wszystkie etapy: kompilację, linkowanie, ładowanie i wykonywanie.

Kilka przykładów, aby to utrwalić, pamiętaj, że żadna sekcja nie jest wyczerpująca:

  • kompilator może założyć, że fragmenty kodu zawierające niezdefiniowane zachowanie nigdy nie są wykonywane, a zatem przyjmuje, że ścieżki wykonywania, które prowadziłyby do nich, są martwym kodem. Zobacz, co każdy programista C powinien wiedzieć o niezdefiniowanym zachowaniu nikogo innego niż Chrisa Lattnera.
  • linker może założyć, że w obecności wielu definicji słabego symbolu (rozpoznawanego z nazwy), wszystkie definicje są identyczne dzięki zasadzie jednej definicji
  • moduł ładujący (w przypadku korzystania z bibliotek dynamicznych) może przyjąć to samo, wybierając w ten sposób pierwszy znaleziony symbol; jest to zwykle (ab) używane do przechwytywania połączeń przy użyciu LD_PRELOADsztuczek na Uniksie
  • wykonanie może się nie powieść (SIGSEV), jeśli użyjesz wiszących wskaźników

To właśnie jest tak przerażające w niezdefiniowanym zachowaniu: prawie niemożliwe jest przewidzenie z wyprzedzeniem, jakie dokładne zachowanie wystąpi, i tę prognozę należy ponownie przeanalizować przy każdej aktualizacji łańcucha narzędzi, podstawowego systemu operacyjnego, ...


Polecam obejrzenie tego filmu autorstwa Michaela Spencera (LLVM Developer): CppCon 2016: My Little Optimizer: Undefined Behavior is Magic .

Matthieu M.
źródło
3
To mnie martwi. W moim prawdziwym kodzie, to skomplikowane, ale może mieć to przypadek, w którym zawsze będzie przepełnienie. I tak naprawdę mnie to nie obchodzi, ale martwię się, że wpłynie to również na „poprawny” kod. Oczywiście muszę to naprawić, ale naprawa wymaga zrozumienia :)
jcoder
8
@jcoder: Jest tu jedna ważna ucieczka. Kompilator nie może odgadnąć danych wejściowych. Dopóki istnieje co najmniej jedno wejście, dla którego nie występuje niezdefiniowane zachowanie, kompilator musi zapewnić, że dane wejście nadal generuje prawidłowe dane wyjściowe. Wszystkie przerażające rozmowy o niebezpiecznych optymalizacjach dotyczą tylko nieuniknionego UB. Praktycznie rzecz biorąc, gdybyś użył argcjako licznika pętli, przypadek argc=1nie generuje UB i kompilator byłby zmuszony do obsługi tego.
MSalters
@jcoder: w tym przypadku nie jest to martwy kod. Kompilator może być jednak wystarczająco inteligentny, aby wywnioskować, że inie można go zwiększać więcej niż Nrazy, a zatem jego wartość jest ograniczona.
Matthieu M.,
4
@jcoder: Jeśli f(good);robi coś X i f(bad);wywołuje niezdefiniowane zachowanie, to program, który po prostu wywołuje f(good);X, z pewnością wykona X, ale f(good); f(bad);nie ma gwarancji, że zrobi X.
4
@Hurkyl co ciekawsze, jeśli twój kod jest if(foo) f(good); else f(bad);, inteligentny kompilator odrzuci porównanie i stworzy bezwarunkową foo(good).
John Dvorak
28

Agresywnie optymalizujący kompilator C lub C ++ przeznaczony dla wersji 16-bitowej intbędzie wiedział, że zachowanie przy dodawaniu 1000000000do inttypu jest niezdefiniowane .

Każdy standard zezwala na robienie wszystkiego, co chce, co może obejmować usunięcie całego programu, opuszczenie go int main(){}.

Ale co z większymi int? Nie znam kompilatora, który to robi (i nie jestem ekspertem w projektowaniu kompilatorów C i C ++ w żadnym wypadku), ale wyobrażam sobie, że kiedyś kompilator przeznaczony dla wersji 32-bitowej intlub wyższej zorientuje się, że pętla jest nieskończony ( inie zmienia się) , a więc aostatecznie przepełnienia. Więc po raz kolejny może zoptymalizować wyjście do int main(){}. Chodzi mi o to, że w miarę jak optymalizacje kompilatora stają się coraz bardziej agresywne, coraz więcej niezdefiniowanych konstrukcji zachowań objawia się w nieoczekiwany sposób.

Fakt, że pętla jest nieskończona, sam w sobie nie jest nieokreślony, ponieważ piszesz na standardowe wyjście w treści pętli.

Batszeba
źródło
3
Czy norma zezwala na robienie czegokolwiek, co chce, nawet zanim ujawni się niezdefiniowane zachowanie? Gdzie to jest powiedziane?
jimifiki,
4
dlaczego 16 bitów? Wydaje mi się, że OP szuka przepełnienia ze znakiem 32-bitowym.
4386427
8
@jimifiki w standardzie. C ++ 14 (N4140) 1.3.24 "udnefined zachowanie = zachowanie, dla którego niniejsza Norma Międzynarodowa nie narzuca żadnych wymagań." Plus obszerna notatka. Ale chodzi o to, że to nie zachowanie „instrukcji” jest niezdefiniowane, lecz zachowanie programu. Oznacza to, że dopóki UB jest uruchamiane przez regułę w standardzie (lub przez brak reguły), norma przestaje obowiązywać dla programu jako całości. Zatem każda część programu może zachowywać się tak, jak chce.
Angew nie jest już dumny z SO
5
Pierwsze stwierdzenie jest błędne. Jeśli intjest 16-bitowy, dodawanie nastąpi long(ponieważ operand literału ma typ long), w którym jest dobrze zdefiniowany, a następnie zostanie przekonwertowany przez konwersję zdefiniowaną w implementacji z powrotem na int.
R .. GitHub STOP HELPING ICE
2
@usr zachowanie printfjest zdefiniowane przez standard, aby zawsze zwracać
MM
11

Z technicznego punktu widzenia, zgodnie ze standardem C ++, jeśli program zawiera niezdefiniowane zachowanie, zachowanie całego programu, nawet w czasie kompilacji (przed wykonaniem programu), jest niezdefiniowane.

W praktyce, ponieważ kompilator może założyć (w ramach optymalizacji), że przepełnienie nie wystąpi, przynajmniej zachowanie programu w trzeciej iteracji pętli (przy założeniu maszyny 32-bitowej) będzie niezdefiniowane, chociaż jest prawdopodobne, że otrzymasz prawidłowe wyniki przed trzecią iteracją. Jednakże, ponieważ zachowanie całego programu jest technicznie niezdefiniowane, nic nie stoi na przeszkodzie, aby program generował całkowicie niepoprawne dane wyjściowe (w tym brak danych wyjściowych), zawieszał się w czasie wykonywania w dowolnym momencie podczas wykonywania, a nawet nie mógł całkowicie się skompilować (ponieważ niezdefiniowane zachowanie rozciąga się na czas kompilacji).

Niezdefiniowane zachowanie zapewnia kompilatorowi więcej miejsca na optymalizację, ponieważ eliminuje pewne założenia dotyczące tego, co musi zrobić kod. W ten sposób programy, które opierają się na założeniach dotyczących nieokreślonego zachowania, nie mają gwarancji, że będą działać zgodnie z oczekiwaniami. W związku z tym nie należy polegać na żadnym konkretnym zachowaniu, które jest uważane za niezdefiniowane zgodnie ze standardem C ++.

bwDraco
źródło
A jeśli część UB jest objęta if(false) {}zakresem? Czy to zatruwa cały program, ponieważ kompilator zakłada, że ​​wszystkie gałęzie zawierają ~ dobrze zdefiniowane fragmenty logiki, a zatem działają na błędnych założeniach?
mlvljr
1
Norma nie nakłada żadnych wymagań na niezdefiniowane zachowanie, więc teoretycznie tak, zatruwa cały program. Jednak w praktyce każdy optymalizujący kompilator prawdopodobnie po prostu usunie martwy kod, więc prawdopodobnie nie miałoby to żadnego wpływu na wykonanie. Jednak nadal nie powinieneś polegać na tym zachowaniu.
bwDraco
Dobrze wiedzieć, dzięki :)
mlvljr
9

Aby zrozumieć, dlaczego niezdefiniowane zachowanie może „podróżować w czasie”, jak to odpowiednio ujął @TartanLlama , przyjrzyjmy się zasadzie „as-if”:

1.9 Wykonanie programu

1 Opisy semantyczne w niniejszej Normie Międzynarodowej definiują sparametryzowaną niedeterministyczną maszynę abstrakcyjną. Niniejsza Norma Międzynarodowa nie nakłada żadnych wymagań dotyczących struktury wdrożeń zgodnych. W szczególności nie muszą kopiować ani naśladować struktury abstrakcyjnej maszyny. Raczej, implementacje zgodne są wymagane do emulacji (tylko) obserwowalnego zachowania maszyny abstrakcyjnej, jak wyjaśniono poniżej.

Dzięki temu moglibyśmy postrzegać program jako „czarną skrzynkę” z danymi wejściowymi i wyjściowymi. Dane wejściowe mogą być danymi wejściowymi użytkownika, plikami i wieloma innymi rzeczami. Wynikiem jest „obserwowalne zachowanie” wspomniane w standardzie.

Standard definiuje tylko mapowanie między wejściem a wyjściem, nic więcej. Robi to, opisując „przykładową czarną skrzynkę”, ale wyraźnie mówi, że każda inna czarna skrzynka z tym samym mapowaniem jest równie ważna. Oznacza to, że zawartość czarnej skrzynki jest nieistotna.

Mając to na uwadze, nie ma sensu twierdzenie, że w pewnym momencie zachodzi nieokreślone zachowanie. W przykładowej implementacji czarnej skrzynki moglibyśmy powiedzieć, gdzie i kiedy to się dzieje, ale rzeczywista czarna skrzynka może być czymś zupełnie innym, więc nie możemy już powiedzieć, gdzie i kiedy to się dzieje. Teoretycznie kompilator mógłby na przykład zdecydować się wyliczyć wszystkie możliwe dane wejściowe i wstępnie obliczyć wynikowe dane wyjściowe. Wtedy niezdefiniowane zachowanie miałoby miejsce podczas kompilacji.

Niezdefiniowane zachowanie to brak odwzorowania danych wejściowych i wyjściowych. Program może mieć niezdefiniowane zachowanie dla niektórych danych wejściowych, ale określone zachowanie dla innych. Wtedy odwzorowanie danych wejściowych i wyjściowych jest po prostu niepełne; istnieje wejście, dla którego nie istnieje mapowanie do wyjścia.
Program w pytaniu ma niezdefiniowane zachowanie dla dowolnego wejścia, więc mapowanie jest puste.

alain
źródło
6

Zakładając, że intjest to wersja 32-bitowa, niezdefiniowane zachowanie ma miejsce w trzeciej iteracji. Jeśli na przykład pętla byłaby osiągalna tylko warunkowo lub mogłaby zostać warunkowo zakończona przed trzecią iteracją, nie byłoby nieokreślonego zachowania, chyba że trzecia iteracja została faktycznie osiągnięta. Jednak w przypadku niezdefiniowanego zachowania, wszystkie dane wyjściowe programu są niezdefiniowane, w tym dane wyjściowe, które są „w przeszłości” w stosunku do wywołania niezdefiniowanego zachowania. Na przykład w Twoim przypadku oznacza to, że nie ma gwarancji, że na wyjściu pojawią się 3 komunikaty „Hello”.

R .. GitHub PRZESTAŃ POMÓC LODOWI
źródło
6

Odpowiedź TartanLlama jest prawidłowa. Niezdefiniowane zachowanie może wystąpić w dowolnym momencie, nawet podczas kompilacji. Może się to wydawać absurdalne, ale jest to kluczowa funkcja umożliwiająca kompilatorom robienie tego, co muszą. Nie zawsze jest łatwo być kompilatorem. Za każdym razem musisz robić dokładnie to, co mówi specyfikacja. Czasami jednak udowodnienie, że występuje określone zachowanie, może być potwornie trudne. Jeśli pamiętasz problem z zatrzymaniem, tworzenie oprogramowania, dla którego nie możesz udowodnić, czy kończy lub wchodzi w nieskończoną pętlę po podaniu określonego wejścia, jest raczej trywialne.

Moglibyśmy sprawić, że kompilatory będą pesymistyczne i stale kompilować w obawie, że następna instrukcja może być jednym z takich problemów, jak problemy z zatrzymaniem, ale to nie jest rozsądne. Zamiast tego dajemy kompilatorowi przepustkę: w przypadku tematów „niezdefiniowanego zachowania” są oni zwolnieni z jakiejkolwiek odpowiedzialności. Niezdefiniowane zachowanie składa się ze wszystkich zachowań, które są tak subtelnie nikczemne, że mamy problem z oddzieleniem ich od naprawdę okropnych, nikczemnych problemów z zatrzymaniem i tak dalej.

Jest przykład, który uwielbiam publikować, choć przyznaję, że straciłem źródło, więc muszę sparafrazować. Pochodził z określonej wersji MySQL. W MySQL mieli okrągły bufor, który był wypełniony danymi dostarczonymi przez użytkownika. Oczywiście chcieli się upewnić, że dane nie przepełnią bufora, więc mieli sprawdzenie:

if (currentPtr + numberOfNewChars > endOfBufferPtr) { doOverflowLogic(); }

Wygląda wystarczająco rozsądnie. A co jeśli numberOfNewChars jest naprawdę duży i przepełnia? Następnie zawija się i staje się wskaźnikiem mniejszym niż endOfBufferPtr, więc logika przepełnienia nigdy nie została wywołana. Więc dodali drugi czek, przed tym:

if (currentPtr + numberOfNewChars < currentPtr) { detectWrapAround(); }

Wygląda na to, że załatwiłeś błąd przepełnienia bufora, prawda? Jednak zgłoszony został błąd informujący o przepełnieniu tego bufora w określonej wersji Debiana! Dokładne badanie wykazało, że ta wersja Debiana była pierwszą, w której zastosowano szczególnie nowatorską wersję gcc. W tej wersji gcc kompilator rozpoznał, że currentPtr + numberOfNewChars nigdy nie może być mniejszym wskaźnikiem niż currentPtr, ponieważ przepełnienie wskaźników jest niezdefiniowanym zachowaniem! To wystarczyło, aby gcc zoptymalizowało całe sprawdzenie i nagle nie byłeś chroniony przed przepełnieniem bufora, mimo że napisałeś kod, aby to sprawdzić!

To było zachowanie zgodne ze specyfikacją. Wszystko było legalne (chociaż z tego, co słyszałem, gcc wycofał tę zmianę w następnej wersji). Nie jest to to, co uważałbym za intuicyjne zachowanie, ale jeśli trochę rozciągniesz wyobraźnię, łatwo zobaczysz, jak niewielki wariant tej sytuacji może stać się problemem zatrzymania kompilatora. Z tego powodu autorzy specyfikacji określili to jako „niezdefiniowane zachowanie” i stwierdzili, że kompilator może zrobić absolutnie wszystko, co mu się podoba.

Cort Ammon
źródło
Nie uważam za szczególnie zadziwiających kompilatorów, które czasami zachowują się tak, jakby arytmetyka ze znakiem jest wykonywana na typach, których zakres wykracza poza "int", szczególnie biorąc pod uwagę, że nawet podczas prostego generowania kodu na x86 są chwile, kiedy jest to bardziej wydajne niż obcinanie pośredniego wyniki. Bardziej zdumiewające jest to, że przepełnienie wpływa na inne obliczenia, co może się zdarzyć w gcc, nawet jeśli kod przechowuje iloczyn dwóch wartości uint16_t w uint32_t - operacja, która nie powinna mieć żadnego wiarygodnego powodu, aby działać zaskakująco w kompilacji, która nie jest dezynfekująca.
supercat
Oczywiście poprawne sprawdzenie byłoby takie if(numberOfNewChars > endOfBufferPtr - currentPtr), pod warunkiem, że numberOfNewChars nigdy nie może być ujemne, a currentPtr zawsze wskazuje gdzieś w buforze, nie potrzebujesz nawet śmiesznego sprawdzania typu „wraparound”. (Nie sądzę, aby kod, który podałeś, miał jakąkolwiek nadzieję na pracę w okrągłym buforze - w parafrazie
pominąłeś
@ Random832 Opuściłem tonę. Próbowałem zacytować szerszy kontekst, ale odkąd straciłem źródło, stwierdziłem, że parafrazowanie kontekstu przysporzyło mi więcej kłopotów, więc pomijam go. Naprawdę muszę znaleźć ten przeklęty raport o błędzie, żeby móc go poprawnie zacytować. To naprawdę potężny przykład tego, jak możesz myśleć, że napisałeś kod w jeden sposób i skompilowałeś go zupełnie inaczej.
Cort Ammon
To jest mój największy problem z niezdefiniowanym zachowaniem. Uniemożliwia czasem napisanie poprawnego kodu, a gdy kompilator go wykryje, domyślnie nie informuje, że wyzwolił niezdefiniowane zachowanie. W tym przypadku użytkownik po prostu chce wykonywać obliczenia arytmetyczne - wskazując lub nie - i cała ich ciężka praca nad napisaniem bezpiecznego kodu została cofnięta. Powinien istnieć przynajmniej sposób na dodanie adnotacji do sekcji kodu - nie ma tu żadnych wymyślnych optymalizacji. C / C ++ jest używany w zbyt wielu krytycznych obszarach, aby pozwolić tej niebezpiecznej sytuacji na kontynuację na rzecz optymalizacji
John McGrath
4

Poza odpowiedziami teoretycznymi, praktyczna obserwacja byłaby taka, że ​​przez długi czas kompilatorzy stosowali różne transformacje pętli, aby zmniejszyć ilość pracy wykonywanej w nich. Na przykład, biorąc pod uwagę:

for (int i=0; i<n; i++)
  foo[i] = i*scale;

kompilator może przekształcić to w:

int temp = 0;
for (int i=0; i<n; i++)
{
  foo[i] = temp;
  temp+=scale;
}

Zapisuje w ten sposób mnożenie przy każdej iteracji pętli. Dodatkowa forma optymalizacji, którą kompilatory dostosowały z różnym stopniem agresywności, zmieniłaby to w:

if (n > 0)
{
  int temp1 = n*scale;
  int *temp2 = foo;
  do
  {
    temp1 -= scale;
    *temp2++ = temp1;
  } while(temp1);
}

Nawet na maszynach z cichym zawijaniem przy przepełnieniu, mogłoby to działać nieprawidłowo, gdyby była jakaś liczba mniejsza niż n, która po pomnożeniu przez skalę dałaby 0. Może również przekształcić się w niekończącą się pętlę, gdyby skala była odczytywana z pamięci więcej niż raz i coś nieoczekiwanie zmienił swoją wartość (w każdym przypadku, gdy „skala” mogłaby zmienić pętlę w połowie bez wywoływania UB, kompilator nie mógł przeprowadzić optymalizacji).

Podczas gdy większość takich optymalizacji nie miałaby żadnych problemów w przypadkach, gdy dwa krótkie typy bez znaku są mnożone w celu uzyskania wartości mieszczącej się między INT_MAX + 1 i UINT_MAX, gcc ma pewne przypadki, w których takie mnożenie w pętli może spowodować wczesne zakończenie pętli . Nie zauważyłem takich zachowań wynikających z instrukcji porównawczych w wygenerowanym kodzie, ale można to zaobserwować w przypadkach, gdy kompilator używa przepełnienia, aby wywnioskować, że pętla może zostać wykonana maksymalnie 4 lub mniej razy; domyślnie nie generuje ostrzeżeń w przypadkach, gdy niektóre dane wejściowe spowodowałyby UB, a inne nie, nawet jeśli jego wnioski powodują ignorowanie górnej granicy pętli.

supercat
źródło
4

Niezdefiniowane zachowanie to z definicji szara strefa. Po prostu nie można przewidzieć, co będzie lub nie zrobi - to „niezdefiniowane zachowanie” środki .

Od niepamiętnych czasów programiści zawsze próbowali ocalić resztki definicji z nieokreślonej sytuacji. Mają kod, którego naprawdę chcą użyć, ale okazuje się, że jest niezdefiniowany, więc próbują się kłócić: „Wiem, że to nieokreślone, ale na pewno w najgorszym przypadku zrobi to lub to; nigdy nie zrobi tego ”. Czasami te argumenty są mniej więcej słuszne - ale często są błędne. A gdy kompilatory stają się coraz mądrzejsze (lub, niektórzy ludzie mogą powiedzieć, bardziej podstępne i podstępne), granice pytania ciągle się zmieniają.

Tak więc, jeśli chcesz napisać kod, który na pewno będzie działał i będzie działał przez długi czas, jest tylko jeden wybór: unikaj niezdefiniowanego zachowania za wszelką cenę. Zaprawdę, jeśli będziesz się tym bawić, wróci, by cię prześladować.

Steve Summit
źródło
a jednak chodzi o to ... kompilatory mogą używać niezdefiniowanego zachowania do optymalizacji, ale NA OGÓLNIE CI NIE MÓWIĄ. Więc jeśli mamy to niesamowite narzędzie, którego musisz unikać za wszelką cenę, dlaczego kompilator nie może dać ci ostrzeżenia, abyś mógł to naprawić?
Jason S
1

Jedyną rzeczą, której Twój przykład nie bierze pod uwagę, jest optymalizacja. ajest ustawiana w pętli, ale nigdy nie jest używana, a optymalizator mógłby to rozwiązać. W związku z tym optymalizator ma prawo acałkowicie odrzucić , aw takim przypadku wszelkie niezdefiniowane zachowanie znika jak ofiara boojum.

Jednak to oczywiście samo w sobie jest niezdefiniowane, ponieważ optymalizacja jest niezdefiniowana. :)

Graham
źródło
1
Nie ma powodu, aby rozważać optymalizację przy określaniu, czy zachowanie jest niezdefiniowane.
Keith Thompson
2
Fakt, że program zachowuje się tak, jak można by przypuszczać, nie oznacza, że ​​niezdefiniowane zachowanie „znika”. Zachowanie jest nadal nieokreślone i po prostu polegasz na szczęściu. Sam fakt, że zachowanie programu może się zmieniać w zależności od opcji kompilatora, jest silnym wskaźnikiem, że zachowanie jest niezdefiniowane.
Jordan Melo,
@JordanMelo Ponieważ wiele poprzednich odpowiedzi dotyczyło optymalizacji (a OP był o to specjalnie pytany), wspomniałem o funkcji optymalizacji, której żadna poprzednia odpowiedź nie obejmowała. Zwróciłem również uwagę, że chociaż optymalizacja może go usunąć, poleganie na optymalizacji, aby działała w jakikolwiek określony sposób, jest ponownie niezdefiniowane. Z pewnością tego nie polecam! :)
Graham
@KeithThompson Jasne, ale operator operacyjny zapytał konkretnie o optymalizację i jej wpływ na niezdefiniowane zachowanie, które zobaczy na swojej platformie. To specyficzne zachowanie może zniknąć w zależności od optymalizacji. Jak powiedziałem w mojej odpowiedzi, nieokreśloność nie.
Graham
0

Ponieważ to pytanie jest podwójnie oznaczone C i C ++, spróbuję rozwiązać oba. C i C ++ przyjmują tutaj różne podejścia.

W C implementacja musi być w stanie udowodnić, że niezdefiniowane zachowanie zostanie wywołane, aby traktować cały program tak, jakby miał niezdefiniowane zachowanie. W przykładzie PO udowodnienie tego przez kompilatora wydaje się trywialne i dlatego wydaje się, że cały program był niezdefiniowany.

Możemy to zobaczyć w raporcie o defektach 109, który w swej istocie pyta:

Jeśli jednak standard C rozpoznaje odrębne istnienie „niezdefiniowanych wartości” (których samo tworzenie nie obejmuje całkowicie „nieokreślonego zachowania”), osoba przeprowadzająca testy kompilatora mogłaby napisać przypadek testowy, taki jak poniższy, i mógłby również oczekiwać (lub ewentualnie wymagać), aby implementacja zgodna z wymaganiami przynajmniej skompilowała ten kod (i prawdopodobnie umożliwiła mu wykonanie) bez „niepowodzenia”.

int array1[5];
int array2[5];
int *p1 = &array1[0];
int *p2 = &array2[0];

int foo()
{
int i;
i = (p1 > p2); /* Must this be "successfully translated"? */
1/0; /* Must this be "successfully translated"? */
return 0;
}

Zatem najważniejsze pytanie brzmi: czy powyższy kod musi zostać „pomyślnie przetłumaczony” (cokolwiek to znaczy)? (Patrz przypis dołączony do podrozdziału 5.1.1.3.)

a odpowiedź brzmiała:

W standardzie C zastosowano termin „o wartości nieokreślonej”, a nie „wartość nieokreślona”. Użycie nieokreślonego obiektu wartościowego powoduje niezdefiniowane zachowanie. Przypis do podpunktu 5.1.1.3 wskazuje, że implementacja może generować dowolną liczbę diagnostyki, o ile poprawny program jest nadal poprawnie przetłumaczony. Jeśli wyrażenie, którego ewaluacja spowodowałaby niezdefiniowane zachowanie, pojawia się w kontekście, w którym wymagane jest stałe wyrażenie, to zawierający program nie jest ściśle zgodny. Ponadto, jeśli każde możliwe wykonanie danego programu powodowałoby niezdefiniowane zachowanie, to dany program nie jest ściśle zgodny. Zgodna implementacja nie może zawieść w tłumaczeniu ściśle zgodnego programu po prostu dlatego, że niektóre możliwe wykonanie tego programu spowodowałoby niezdefiniowane zachowanie. Ponieważ foo może nigdy nie zostać wywołane, podany przykład musi zostać pomyślnie przetłumaczony przez zgodną implementację.

W C ++ podejście to wydaje się bardziej swobodne i sugerowałoby, że program ma niezdefiniowane zachowanie, niezależnie od tego, czy implementacja może to udowodnić statycznie, czy nie.

Mamy [intro.abstrac] p5, który mówi:

Zgodna implementacja wykonująca dobrze uformowany program będzie dawać takie samo obserwowalne zachowanie, jak jedno z możliwych wykonań odpowiedniej instancji maszyny abstrakcyjnej z tym samym programem i tymi samymi danymi wejściowymi. Jeśli jednak takie wykonanie zawiera niezdefiniowaną operację, dokument ten nie nakłada żadnych wymagań na implementację wykonującą ten program z tym wejściem (nawet w odniesieniu do operacji poprzedzających pierwszą niezdefiniowaną operację).

Shafik Yaghmour
źródło
Fakt, że wykonanie funkcji wywołałoby UB, może wpłynąć tylko na sposób, w jaki program zachowuje się, gdy otrzyma określone dane wejściowe, jeśli przynajmniej jedno możliwe wykonanie programu, gdy zostanie podane to wejście, wywołałoby UB. Fakt, że wywołanie funkcji wywołałoby UB, nie zapobiega zdefiniowaniu przez program zachowania, gdy otrzymuje dane wejściowe, które nie pozwalają na wywołanie funkcji.
supercat
@supercat Wydaje mi się, że to jest moja odpowiedź przynajmniej dla C.
Shafik Yaghmour,
Myślę, że to samo odnosi się do cytowanego tekstu w C ++, ponieważ fraza „Każde takie wykonanie” odnosi się do sposobów, w jakie program mógłby wykonać się przy określonym wejściu. Jeśli określone wejście nie może spowodować wykonania funkcji, nie widzę w cytowanym tekście nic, co mogłoby powiedzieć, że cokolwiek w takiej funkcji spowodowałoby UB.
supercat
-2

Najlepsza odpowiedź to błędne (ale powszechne) nieporozumienie:

Niezdefiniowane zachowanie jest właściwością czasu wykonywania *. To NIE MOŻE „podróży w czasie”!

Niektóre operacje są zdefiniowane (standardowo) jako mające skutki uboczne i nie można ich zoptymalizować. Operacje, które wykonują operacje we / wy lub uzyskują dostęp do volatilezmiennych, należą do tej kategorii.

Istnieje jednak zastrzeżenie: UB może być dowolnym zachowaniem, w tym zachowaniem, które cofa poprzednie operacje. W niektórych przypadkach może to mieć podobne konsekwencje do optymalizacji wcześniejszego kodu.

W rzeczywistości jest to zgodne z cytatem w górnej odpowiedzi (moje wyróżnienie):

Zgodna implementacja wykonująca dobrze uformowany program będzie dawać takie samo obserwowalne zachowanie, jak jedno z możliwych wykonań odpowiedniej instancji maszyny abstrakcyjnej z tym samym programem i tymi samymi danymi wejściowymi.
Jeśli jednak takie wykonanie zawiera nieokreśloną operację, niniejsza Norma Międzynarodowa nie nakłada żadnych wymagań na implementację wykonującą ten program z tym wejściem (nawet w odniesieniu do operacji poprzedzających pierwszą niezdefiniowaną operację).

Tak, ten cytat nie powiedzieć „nie, nawet w odniesieniu do operacji poprzedzających pierwszy niezdefiniowanej operacji” , ale zauważ, że to jest konkretnie o kod, który jest wykonany , nie tylko skompilowany.
W końcu niezdefiniowane zachowanie, które w rzeczywistości nie jest osiągnięte, nic nie robi, a aby wiersz zawierający UB został faktycznie osiągnięty, kod, który go poprzedza, musi zostać wykonany jako pierwszy!

Więc tak, po wykonaniu UB wszelkie efekty poprzednich operacji stają się niezdefiniowane. Ale dopóki to się nie stanie, wykonanie programu jest dobrze zdefiniowane.

Należy jednak pamiętać, że wszystkie uruchomienia programu, które powodują takie zdarzenie, można zoptymalizować pod kątem równoważnych programów, w tym programów wykonujących poprzednie operacje, ale następnie cofających ich efekty. W konsekwencji poprzedni kod może zostać zoptymalizowany, jeśli byłoby to równoważne z cofnięciem ich skutków ; w przeciwnym razie nie może. Poniżej przykład.

* Uwaga: nie jest to niespójne z UB występującym w czasie kompilacji . Jeśli kompilator rzeczywiście może udowodnić, że kod UB będzie zawsze wykonywany dla wszystkich danych wejściowych, wówczas UB może wydłużyć czas kompilacji. Wymaga to jednak wiedzy, że cały poprzedni kod w końcu powróci , co jest silnym wymogiem. Ponownie, zobacz poniżej przykład / wyjaśnienie.


Aby było to konkretne, zwróć uwagę, że poniższy kod musi wydrukować fooi czekać na dane wejściowe, niezależnie od jakiegokolwiek niezdefiniowanego zachowania, które po nim następuje:

printf("foo");
getchar();
*(char*)1 = 1;

Należy jednak pamiętać, że nie ma gwarancji, że foopozostanie na ekranie po wystąpieniu UB, ani że wpisany znak nie będzie już w buforze wejściowym; obie te operacje można „cofnąć”, co ma podobny efekt do „podróży w czasie” UB.

Gdyby getchar()linii nie było, byłoby to dozwolone , gdyby linie były zoptymalizowane, wtedy i tylko wtedy , gdy byłoby to nie do odróżnienia od wyprowadzania, fooa następnie „usuwania”.

To, czy te dwa elementy byłyby nierozróżnialne, zależałoby całkowicie od implementacji (tj. Od kompilatora i biblioteki standardowej). Na przykład, czy możesz printf zablokować tutaj swój wątek, czekając na inny program, aby odczytać dane wyjściowe? A może natychmiast wróci?

  • Jeśli może się tutaj zablokować, wówczas inny program może odmówić odczytania pełnego wyjścia i może nigdy nie powrócić, aw konsekwencji UB może nigdy nie wystąpić.

  • Jeśli może natychmiast powrócić tutaj, to wiemy, że musi powrócić, a zatem optymalizacja jest całkowicie nie do odróżnienia od wykonywania, a następnie cofania efektów.

Oczywiście, ponieważ kompilator wie, jakie zachowanie jest dopuszczalne dla jego konkretnej wersji printf, może odpowiednio zoptymalizować, a co za tym idzie printf, w niektórych przypadkach może zostać zoptymalizowany, aw innych nie. Ale, znowu, uzasadnienie jest takie, że byłoby to nie do odróżnienia od niewykonywania poprzednich operacji przez UB, a nie, że poprzedni kod jest „zatruty” z powodu UB.

user541686
źródło
1
Całkowicie źle czytasz standard. Mówi, że zachowanie podczas wykonywania programu jest nieokreślone. Kropka. Ta odpowiedź jest w 100% błędna. Standard jest bardzo jasny - uruchomienie programu z danymi wejściowymi, które generuje UB w dowolnym momencie naiwnego przepływu wykonywania, jest niezdefiniowane.
David Schwartz
@DavidSchwartz: Jeśli podążasz za swoją interpretacją do jej logicznych wniosków, powinieneś zdać sobie sprawę, że nie ma to logicznego sensu. Dane wejściowe nie są w pełni określane podczas uruchamiania programu. Dane wejściowe programu (nawet jego sama obecność ) w dowolnej linii mogą zależeć od wszystkich skutków ubocznych programu aż do tej linii. Dlatego program nie może uniknąć wywoływania skutków ubocznych, które pojawiają się przed linią UB, ponieważ wymaga to interakcji z jego otoczeniem, a zatem wpływa w pierwszej kolejności na to, czy linia UB zostanie osiągnięta, czy nie.
user541686
3
To nie ma znaczenia. Naprawdę. Znowu brakuje ci wyobraźni. Na przykład, jeśli kompilator może stwierdzić, że żaden zgodny kod nie może stwierdzić różnicy, może przesunąć kod, który jest UB, tak, że część, która jest UB, jest wykonywana przed wyjściami, które naiwnie oczekujesz, że będą „poprzedzające”.
David Schwartz
2
@Mehrdad: Być może lepszym sposobem na powiedzenie rzeczy byłoby stwierdzenie, że UB nie może cofnąć się w czasie poza ostatni punkt, w którym mogło się wydarzyć coś w prawdziwym świecie, co zdefiniowałoby zachowanie. Gdyby implementacja mogła ustalić, badając bufory wejściowe, że nie było możliwości zablokowania żadnego z następnych 1000 wywołań funkcji getchar (), a także mogłaby ustalić, że UB wystąpi po 1000-tym wywołaniu, nie byłoby wymagane wykonywanie żadnego z rozmowy. Jeśli jednak implementacja określiłaby, że wykonanie nie przejdzie funkcji getchar (), dopóki wszystkie poprzednie dane wyjściowe nie będą miały ...
supercat
2
... został dostarczony do terminala 300 bodów i że jakiekolwiek sterowanie-C, które nastąpi wcześniej, spowoduje, że getchar () podniesie sygnał, nawet jeśli były inne znaki w poprzedzającym go buforze, to taka implementacja nie może przesuń dowolny UB poza ostatnie wyjście poprzedzające getchar (). Trudna jest wiedza, w jakim przypadku należy oczekiwać, że kompilator przekaże programistę wszelkie gwarancje behawioralne, które implementacja biblioteki może zaoferować poza tymi, które są wymagane przez Standard.
supercat