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ż a
przepeł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).
c++
c
undefined-behavior
integer-overflow
jcoder
źródło
źródło
a
nie jest używany (z wyjątkiem samego obliczenia) i po prostu usunąća
Odpowiedzi:
Jeśli interesuje Cię czysto teoretyczna odpowiedź, standard C ++ zezwala na niezdefiniowane zachowanie na „podróż w czasie”:
W związku z tym, jeśli twój program zawiera niezdefiniowane zachowanie, to zachowanie całego programu jest niezdefiniowane.
źródło
sneeze()
sama funkcja jest niezdefiniowana w żadnej klasieDemon
(której podklasą jest odmiana nosowa), co i tak sprawia, że całość jest okrągła.printf
nie zwraca, ale jeśliprintf
ma powrócić, to niezdefiniowane zachowanie może powodować problemy przedprintf
wywołaniem. Stąd podróże w czasie.printf("Hello\n");
a następnie następna linia kompiluje się jakoundoPrintf(); launchNuclearMissiles();
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:
LD_PRELOAD
sztuczek na UniksieTo 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 .
źródło
argc
jako licznika pętli, przypadekargc=1
nie generuje UB i kompilator byłby zmuszony do obsługi tego.i
nie można go zwiększać więcej niżN
razy, a zatem jego wartość jest ograniczona.f(good);
robi coś X if(bad);
wywołuje niezdefiniowane zachowanie, to program, który po prostu wywołujef(good);
X, z pewnością wykona X, alef(good); f(bad);
nie ma gwarancji, że zrobi X.if(foo) f(good); else f(bad);
, inteligentny kompilator odrzuci porównanie i stworzy bezwarunkowąfoo(good)
.Agresywnie optymalizujący kompilator C lub C ++ przeznaczony dla wersji 16-bitowej
int
będzie wiedział, że zachowanie przy dodawaniu1000000000
doint
typu 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-bitowejint
lub wyższej zorientuje się, że pętla jest nieskończony (i
nie zmienia się) , a więca
ostatecznie przepełnienia. Więc po raz kolejny może zoptymalizować wyjście doint 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.
źródło
int
jest 16-bitowy, dodawanie nastąpilong
(ponieważ operand literału ma typlong
), w którym jest dobrze zdefiniowany, a następnie zostanie przekonwertowany przez konwersję zdefiniowaną w implementacji z powrotem naint
.printf
jest zdefiniowane przez standard, aby zawsze zwracać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 ++.
źródło
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?Aby zrozumieć, dlaczego niezdefiniowane zachowanie może „podróżować w czasie”, jak to odpowiednio ujął @TartanLlama , przyjrzyjmy się zasadzie „as-if”:
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.
źródło
Zakładając, że
int
jest 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ódło
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.
źródło
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 parafraziePoza 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.
źródło
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ć.
źródło
Jedyną rzeczą, której Twój przykład nie bierze pod uwagę, jest optymalizacja.
a
jest 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 prawoa
cał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. :)
źródło
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:
a odpowiedź brzmiała:
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:
źródło
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
volatile
zmiennych, 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):
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ć
foo
i 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
foo
pozostanie 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,foo
a 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 idzieprintf
, 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.źródło