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ę?
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ł:
jeżeli jakiekolwiek takie wykonanie zawiera nieokreśloną operację, niniejsza Norma Międzynarodowa nie nakłada żadnych wymagań na implementację wykonującą ten program z tymi danymi wejściowymi (nawet w odniesieniu do operacji poprzedzających pierwszą niezdefiniowaną operację)
Można to zinterpretować:
Jeśli wykonanie programu daje niezdefiniowane zachowanie, to cały program ma niezdefiniowane zachowanie.
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:
- Zachowanie jest niezdefiniowane tylko wtedy, gdy odczytuje się 3.
- Kompilatory mogą i eliminują kod jako martwy, jeśli podstawowy blok zawiera operację, która z pewnością jest niezdefiniowana. Są dozwolone (i przypuszczam, że tak) w przypadkach, które nie są podstawowym blokiem, ale wszystkie gałęzie prowadzą do UB. Ten przykład nie jest kandydatem, chyba że w
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ń”.