Rozważ następujące stwierdzenie:
*((char*)NULL) = 0; //undefined behavior
Wyraźnie wywołuje niezdefiniowane zachowanie. Czy istnienie takiej instrukcji w danym programie oznacza, że cały program jest niezdefiniowany, czy też zachowanie staje się niezdefiniowane dopiero, gdy przepływ sterowania osiągnie tę instrukcję?
Czy następujący program byłby dobrze zdefiniowany, gdyby użytkownik nigdy nie wprowadził numeru 3
?
while (true) {
int num = ReadNumberFromConsole();
if (num == 3)
*((char*)NULL) = 0; //undefined behavior
}
A może jest to całkowicie niezdefiniowane zachowanie bez względu na to, co wprowadzi użytkownik?
Czy kompilator może również założyć, że niezdefiniowane zachowanie nigdy nie zostanie wykonane w czasie wykonywania? To pozwoliłoby na cofanie się w czasie:
int num = ReadNumberFromConsole();
if (num == 3) {
PrintToConsole(num);
*((char*)NULL) = 0; //undefined behavior
}
W tym przypadku kompilator może stwierdzić, że w przypadku, num == 3
gdy zawsze będziemy wywoływać niezdefiniowane zachowanie. Dlatego ten przypadek musi być niemożliwy, a numer nie musi być drukowany. Cała if
instrukcja mogłaby zostać zoptymalizowana. Czy tego rodzaju rozumowanie wstecz jest dozwolone zgodnie ze standardem?
const int i = 0; if (i) 5/i;
.PrintToConsole
nie wywołuje,std::exit
więc musi wykonać to wywołanie.Odpowiedzi:
Ani. Pierwszy warunek jest za silny, a drugi za słaby.
Dostęp do obiektów jest czasami sekwencjonowany, ale standard opisuje zachowanie programu poza czasem. Danvil już cytował:
Można to zinterpretować:
Tak więc nieosiągalna instrukcja z UB nie daje programowi UB. Osiągalna instrukcja, która (ze względu na wartości wejść) nigdy nie jest osiągnięta, nie daje programowi UB. Dlatego twój pierwszy stan jest zbyt silny.
Teraz kompilator nie może ogólnie powiedzieć, co ma UB. Tak więc, aby umożliwić optymalizatorowi zmianę kolejności instrukcji z potencjalnym UB, który byłby możliwy do ponownego uporządkowania w przypadku zdefiniowania ich zachowania, konieczne jest zezwolenie UB na „cofnięcie się w czasie” i popełnienie błędu przed poprzednim punktem sekwencji (lub w C ++ 11 terminologia, aby UB wpływał na rzeczy, które są sekwencjonowane przed rzeczą UB). Dlatego twój drugi stan jest zbyt słaby.
Głównym tego przykładem jest sytuacja, w której optymalizator opiera się na ścisłym aliasingu. Cały sens ścisłych reguł aliasingu polega na umożliwieniu kompilatorowi zmiany kolejności operacji, które nie mogłyby zostać poprawnie uporządkowane, gdyby było możliwe, że odnośne wskaźniki aliasują tę samą pamięć. Więc jeśli użyjesz nielegalnych wskaźników aliasingu, a UB wystąpi, może to łatwo wpłynąć na instrukcję „przed” instrukcją UB. Jeśli chodzi o maszynę abstrakcyjną, instrukcja UB nie została jeszcze wykonana. Jeśli chodzi o rzeczywisty kod wynikowy, został on częściowo lub w całości wykonany. Ale norma nie próbuje wchodzić w szczegóły dotyczące tego, co oznacza dla optymalizatora ponowne uporządkowanie instrukcji ani jakie są tego konsekwencje dla UB. Po prostu daje licencję wdrożeniową na błąd, gdy tylko zechce.
Możesz myśleć o tym jako o „UB ma maszynę czasu”.
W szczególności, aby odpowiedzieć na twoje przykłady:
PrintToConsole(3)
jakiś sposób wiadomo, że wróci. Może zgłosić wyjątek lub cokolwiek.Podobnym przykładem do twojego drugiego jest opcja gcc
-fdelete-null-pointer-checks
, która może przyjmować taki kod (nie sprawdzałem tego konkretnego przykładu, uważam, że ilustruje on ogólną ideę):void foo(int *p) { if (p) *p = 3; std::cout << *p << '\n'; }
i zmień go na:
*p = 3; std::cout << "3\n";
Czemu? Ponieważ jeśli
p
jest null, to i tak kod ma UB, więc kompilator może założyć, że nie jest null i odpowiednio zoptymalizować. Jądro Linuksa potknęło się o to ( https://web.nvd.nist.gov/view/vuln/detail?vulnId=CVE-2009-1897 ) głównie dlatego, że działa w trybie, w którym wyłuskiwanie wskaźnika zerowego nie powinno być UB, oczekuje się, że spowoduje zdefiniowany wyjątek sprzętowy, który jądro może obsłużyć. Gdy optymalizacja jest włączona, gcc wymaga użycia-fno-delete-null-pointer-checks
w celu zapewnienia ponadstandardowej gwarancji.PS Praktyczna odpowiedź na pytanie „kiedy pojawia się niezdefiniowane zachowanie?” to „10 minut przed planowanym wyjazdem na cały dzień”.
źródło
void can_add(int x) { if (x + 100 < x) complain(); }
mogą być optymalizowane z dala całkowicie, bo jeślix+100
robi” przepełnienie nic się nie dzieje, a jeślix+100
nie przepełnienia, to UB, zgodnie z normą, więc nic nie może się wydarzyć.3
jeśli chce, i spakować się do domu na cały dzień, gdy tylko go zobaczy przychodzący.Norma wskazuje na 1,9 / 4
Ciekawostką jest prawdopodobnie to, co oznacza „zawierać”. Nieco później przy 1,9 / 5 mówi:
Tutaj konkretnie wspomina się o „wykonaniu… z tym wejściem”. Zinterpretowałbym to jako niezdefiniowane zachowanie w jednej możliwej gałęzi, która nie jest teraz wykonywana, nie wpływa na bieżącą gałąź wykonania.
Inną kwestią są jednak założenia oparte na niezdefiniowanym zachowaniu podczas generowania kodu. Zobacz odpowiedź Steve'a Jessopa, aby uzyskać więcej informacji na ten temat.
źródło
Pouczającym przykładem jest
int foo(int x) { int a; if (x) return a; return 0; }
Zarówno obecne GCC, jak i bieżące Clang zoptymalizują to (na x86) do
ponieważ wywnioskowali, że
x
jest to zawsze zero z UB wif (x)
ścieżce sterowania. GCC nawet nie daje ostrzeżenia o użyciu niezainicjowanej wartości! (ponieważ przebieg, który stosuje powyższą logikę, jest uruchamiany przed przebiegiem, który generuje ostrzeżenia o niezainicjowanej wartości)źródło
a
nawet jeśli we wszystkich okolicznościach niezainicjowanya
zostałby przekazany do funkcji, której funkcja nigdy nie zrobiłaby z nią nic)?Obecna robocza wersja robocza C ++ mówi, że w 1.9.4
Na tej podstawie powiedziałbym, że program zawierający niezdefiniowane zachowanie na dowolnej ścieżce wykonywania może zrobić wszystko w każdym momencie swojego wykonania.
Istnieją dwa naprawdę dobre artykuły na temat niezdefiniowanego zachowania i tego, co zwykle robią kompilatory:
źródło
int f(int x) { if (x > 0) return 100/x; else return 100; }
pewnością nigdy nie wywołuje niezdefiniowanego zachowania, mimo że100/0
jest oczywiście niezdefiniowane.printf("Hello, World"); *((char*)NULL) = 0
nie ma gwarancji, że cokolwiek wydrukujesz. Pomaga to w optymalizacji, ponieważ kompilator może dowolnie zmieniać kolejność operacji (oczywiście z zastrzeżeniem ograniczeń zależności), o których wie, że w końcu wystąpią, bez konieczności uwzględniania niezdefiniowanego zachowania.int x,y; std::cin >> x >> y; std::cout << (x+y);
można powiedzieć, że „1 + 1 = 17”, tylko dlatego, że istnieją pewne dane wejściowe, w którychx+y
przepełnienia (czyli UB, ponieważint
jest to typ ze znakiem).Słowo „zachowanie” oznacza, że coś się dzieje . Stan, który nigdy nie jest wykonywany, nie jest „zachowaniem”.
Ilustracja:
*ptr = 0;
Czy to niezdefiniowane zachowanie? Załóżmy, że jesteśmy na 100% pewni
ptr == nullptr
przynajmniej raz podczas wykonywania programu. Odpowiedź powinna brzmieć tak.A co z tym?
if (ptr) *ptr = 0;
Czy to jest nieokreślone? (Pamiętasz
ptr == nullptr
przynajmniej raz?) Mam nadzieję, że nie, bo inaczej nie będziesz w stanie napisać żadnego użytecznego programu.Udzielając tej odpowiedzi, żaden srandardese nie ucierpiał.
źródło
Niezdefiniowane zachowanie pojawia się, gdy program spowoduje niezdefiniowane zachowanie bez względu na to, co stanie się później. Jednak podałeś następujący przykład.
int num = ReadNumberFromConsole(); if (num == 3) { PrintToConsole(num); *((char*)NULL) = 0; //undefined behavior }
Dopóki kompilator nie zna definicji
PrintToConsole
, nie może usunąćif (num == 3)
warunku. Załóżmy, że maszLongAndCamelCaseStdio.h
nagłówek systemowy z następującą deklaracjąPrintToConsole
.void PrintToConsole(int);
Nic zbyt pomocnego, w porządku. Teraz zobaczmy, jak zły (lub może nie tak zły, niezdefiniowany sposób mógł być gorszy) sprzedawca, sprawdzając rzeczywistą definicję tej funkcji.
int printf(const char *, ...); void exit(int); void PrintToConsole(int num) { printf("%d\n", num); exit(0); }
W rzeczywistości kompilator musi założyć, że dowolna funkcja, której kompilator nie wie, co robi, może zakończyć działanie lub zgłosić wyjątek (w przypadku C ++). Możesz zauważyć, że
*((char*)NULL) = 0;
nie zostanie to wykonane, ponieważ wykonanie nie będzie kontynuowane poPrintToConsole
wywołaniu.Nieokreślone zachowanie uderza, gdy
PrintToConsole
faktycznie powraca. Kompilator spodziewa się, że tak się nie stanie (ponieważ spowodowałoby to wykonanie przez program niezdefiniowanego zachowania bez względu na wszystko), dlatego wszystko może się zdarzyć.Zastanówmy się jednak nad czymś innym. Powiedzmy, że robimy sprawdzanie wartości null i używamy zmiennej po sprawdzeniu wartości null.
int putchar(int); const char *warning; void lol_null_check(const char *pointer) { if (!pointer) { warning = "pointer is null"; } putchar(*pointer); }
W tym przypadku łatwo zauważyć, że
lol_null_check
wymaga to wskaźnika innego niż NULL. Przypisanie do globalnejwarning
zmiennej nieulotnej nie jest czymś, co mogłoby zakończyć działanie programu lub zgłosić wyjątek.pointer
Jest nieulotna, więc nie może magicznie zmienić jego wartość w środku funkcji (jeśli tak, to niezdefiniowane zachowanie). Wywołanielol_null_check(NULL)
spowoduje niezdefiniowane zachowanie, które może spowodować nieprzypisanie zmiennej (ponieważ w tym momencie znany jest fakt, że program wykonuje niezdefiniowane zachowanie).Jednak niezdefiniowane zachowanie oznacza, że program może zrobić wszystko. Dlatego nic nie powstrzymuje niezdefiniowanego zachowania przed cofnięciem się w czasie i awarią programu przed wykonaniem pierwszej linii
int main()
. To niezdefiniowane zachowanie, nie musi mieć sensu. Równie dobrze może się zawiesić po wpisaniu 3, ale niezdefiniowane zachowanie cofnie się w czasie i ulegnie awarii, zanim wpiszesz 3. A kto wie, być może niezdefiniowane zachowanie nadpisze pamięć RAM systemu i spowoduje awarię systemu 2 tygodnie później, gdy niezdefiniowany program nie jest uruchomiony.źródło
PrintToConsole
to moja próba wstawienia zewnętrznego efektu ubocznego programu, który jest widoczny nawet po awarii i jest silnie uporządkowany. Chciałem stworzyć sytuację, w której możemy z całą pewnością stwierdzić, czy ta instrukcja została zoptymalizowana. Ale masz rację, że może nigdy nie powrócić; Twój przykład pisania do globalnego może podlegać innym optymalizacjom, które nie są związane z UB. Na przykład nieużywany globalny może zostać usunięty. Masz pomysł na stworzenie zewnętrznego efektu ubocznego w sposób gwarantujący przywrócenie kontroli?volatile
zmienną, mogłaby legalnie wyzwolić operację we / wy, która z kolei mogłaby natychmiast przerwać bieżący wątek; program obsługi przerwań mógłby wtedy zabić wątek, zanim będzie miał szansę wykonać cokolwiek innego. Nie widzę uzasadnienia, dzięki któremu kompilator mógłby wypchnąć niezdefiniowane zachowanie przed tym punktem.Jeśli program dotrze do instrukcji wywołującej niezdefiniowane zachowanie, żadne wymagania nie są nakładane na żadne wyjście / zachowanie programu; nie ma znaczenia, czy miałyby miejsce „przed” czy „po” wywołaniu niezdefiniowanego zachowania.
Twoje rozumowanie dotyczące wszystkich trzech fragmentów kodu jest poprawne. W szczególności kompilator może potraktować każdą instrukcję, która bezwarunkowo wywołuje niezdefiniowane zachowanie, tak jak traktuje GCC
__builtin_unreachable()
: jako wskazówkę optymalizacyjną, że instrukcja jest nieosiągalna (a tym samym, że wszystkie ścieżki kodu prowadzące do niej bezwarunkowo są również nieosiągalne). Możliwe są oczywiście inne podobne optymalizacje.źródło
__builtin_unreachable()
zaczęły pojawiać się efekty, które postępowały wstecz i do przodu w czasie? Biorąc pod uwagę coś takiegoextern volatile uint32_t RESET_TRIGGER; void RESET(void) { RESET_TRIGGER = 0xAA55; __memorybarrier(); __builtin_unreachable(); }
, jak mogę uznać,builtin_unreachable()
że dobrze jest poinformować kompilator, że może pominąćreturn
instrukcję, ale byłoby to raczej inne niż stwierdzenie, że poprzedni kod można pominąć.__builtin_unreachable
zostało osiągnięte. Ten program jest zdefiniowany.restrict
wskaźnik na żywo , zostaną zapisane przy użyciu rozszerzeniaunsigned char*
.Wiele standardów dla wielu rodzajów rzeczy wymaga wiele wysiłku na opisanie rzeczy, których implementacje POWINNY lub NIE POWINNY robić, używając nazewnictwa podobnego do zdefiniowanego w IETF RFC 2119 (choć niekoniecznie cytując definicje w tym dokumencie). W wielu przypadkach opisy rzeczy, które implementacje powinny robić, z wyjątkiem przypadków, w których byłyby bezużyteczne lub niepraktyczne, są ważniejsze niż wymagania, które muszą spełniać wszystkie zgodne implementacje.
Niestety, standardy C i C ++ mają tendencję do unikania opisów rzeczy, które chociaż nie są wymagane w 100%, to jednak nie należy się ich spodziewać po implementacjach wysokiej jakości, które nie dokumentują sprzecznych zachowań. Sugestia, że implementacje powinny coś zrobić, może być postrzegana jako sugerująca, że te, które nie są gorsze, aw przypadkach, w których ogólnie byłoby oczywiste, które zachowania byłyby przydatne lub praktyczne, w porównaniu z niepraktycznymi i bezużytecznymi, w danej implementacji Niewielka dostrzegana potrzeba, aby Norma ingerowała w takie osądy.
Sprytny kompilator mógłby być zgodny ze standardem, eliminując jednocześnie kod, który nie miałby żadnego efektu, z wyjątkiem sytuacji, gdy kod otrzymuje dane wejściowe, które nieuchronnie spowodowałyby niezdefiniowane zachowanie, ale „sprytny” i „głupi” nie są antonimami. Fakt, że autorzy Standardu uznali, że mogą istnieć rodzaje implementacji, w których użyteczne zachowanie w danej sytuacji byłoby bezużyteczne i niepraktyczne, nie oznacza żadnego osądu, czy takie zachowania należy uznać za praktyczne i przydatne dla innych. Gdyby implementacja mogła utrzymać gwarancję behawioralną bez żadnych kosztów poza utratą możliwości przycinania „martwej gałęzi”, prawie każda wartość, jaką kod użytkownika mógłby uzyskać z tej gwarancji, przekroczyłaby koszt jej dostarczenia. Eliminacja martwych gałęzi może być w porządku w przypadkach, w których nieale jeśli w danej sytuacji kod użytkownika mógłby obsłużyć prawie każde możliwe zachowanie inne niż eliminacja martwej gałęzi, każdy wysiłek, jaki kod użytkownika musiałby poświęcić, aby uniknąć UB, prawdopodobnie przekroczyłby wartość uzyskaną z DBE.
źródło
x*y < z
kiedyx*y
się nie przepełnia, aw przypadku przepełnienia daje 0 lub 1 w dowolny sposób, ale bez skutków ubocznych, na większości platform nie ma powodu, dla którego spełnienie drugiego i trzeciego wymagania powinno być droższe niż spełnienie pierwszego, ale jakikolwiek sposób zapisania wyrażenia w celu zagwarantowania zachowania zdefiniowanego przez standard we wszystkich przypadkach może w niektórych przypadkach spowodować znaczne koszty. Pisanie wyrażenia, które(int64_t)x*y < z
może ponad czterokrotnie zwiększyć koszt obliczeń ...(int)((unsigned)x*y) < z
taki sposób, aby zapobiec zastosowaniu przez kompilator czegoś, co mogłoby być użytecznymi podstawieniami algebraicznymi (np. jeśli wie o tymx
iz
są równe i dodatnie, może uprościć oryginalne wyrażeniey<0
, ale wersja używająca zmusi kompilator do wykonania mnożenia). Jeśli kompilator może zagwarantować, nawet jeśli norma tego nie nakazuje, to spełni wymóg „wydajność 0 lub 1 bez skutków ubocznych”, kod użytkownika może dać kompilatorowi możliwości optymalizacji, których w innym przypadku nie mógłby uzyskać.x*y
emisję normalnej wartości w przypadku przepełnienia, ale w ogóle jakiejkolwiek wartości. Konfigurowalny UB w C / C ++ wydaje mi się ważny.