L'esistenza di una tale dichiarazione in un dato programma significa che l'intero programma è indefinito o che il comportamento diventa indefinito solo una volta che il flusso di controllo raggiunge questa affermazione?
Nessuno dei due. La prima condizione è troppo forte e la seconda è troppo debole.
Gli accessi agli oggetti a volte sono sequenziati, ma lo standard descrive il comportamento del programma al di fuori del tempo. Danvil ha già citato:
se tale esecuzione contiene un'operazione indefinita, questo standard internazionale non pone alcun requisito sull'implementazione che esegue quel programma con quell'input (nemmeno per quanto riguarda le operazioni che precedono la prima operazione indefinita)
Questo può essere interpretato:
Se l'esecuzione del programma produce un comportamento indefinito, l'intero programma ha un comportamento indefinito.
Quindi, un'istruzione irraggiungibile con UB non dà al programma UB. Una dichiarazione raggiungibile che (a causa dei valori degli input) non viene mai raggiunta, non dà al programma UB. Ecco perché la tua prima condizione è troppo forte.
Ora, il compilatore non può in generale dire cosa ha UB. Quindi, per consentire all'ottimizzatore di riordinare le istruzioni con potenziale UB che sarebbe riordinabile se il loro comportamento fosse definito, è necessario consentire a UB di "tornare indietro nel tempo" e andare storto prima del punto della sequenza precedente (o in C ++ 11 terminologia, affinché l'UB per influenzare le cose che sono sequenziate prima dell'UB). Quindi la tua seconda condizione è troppo debole.
Un importante esempio di ciò è quando l'ottimizzatore si basa su uno stretto alias. Il punto centrale delle rigide regole di aliasing è quello di consentire al compilatore di riordinare operazioni che non potrebbero essere validamente riordinate se fosse possibile che i puntatori in questione alias la stessa memoria. Quindi, se usi puntatori con aliasing illegale e UB si verifica, allora può facilmente influenzare un'istruzione "prima" dell'istruzione UB. Per quanto riguarda la macchina astratta, l'istruzione UB non è stata ancora eseguita. Per quanto riguarda il codice oggetto effettivo, è stato eseguito parzialmente o completamente. Ma lo standard non cerca di entrare nei dettagli su cosa significhi per l'ottimizzatore riordinare le istruzioni, o quali sono le implicazioni di ciò per UB. Dà solo la licenza di implementazione per andare storto non appena lo desidera.
Puoi pensare a questo come "UB ha una macchina del tempo".
Nello specifico per rispondere ai tuoi esempi:
- Il comportamento è indefinito solo se viene letto 3.
- I compilatori possono ed eliminano il codice come morto se un blocco di base contiene un'operazione che sicuramente non sarà definita. Sono consentiti (e immagino lo facciano) nei casi che non sono un blocco di base ma in cui tutti i rami portano a UB. Questo esempio non è un candidato a meno che non
PrintToConsole(3)
sia noto in qualche modo per essere sicuro di tornare. Potrebbe generare un'eccezione o altro.
Un esempio simile al tuo secondo è l'opzione gcc -fdelete-null-pointer-checks
, che può accettare codice come questo (non ho controllato questo esempio specifico, consideralo illustrativo dell'idea generale):
void foo(int *p) {
if (p) *p = 3;
std::cout << *p << '\n';
}
e modificalo in:
*p = 3;
std::cout << "3\n";
Perché? Perché se p
è nullo, il codice ha comunque UB, quindi il compilatore può presumere che non sia nullo e ottimizzare di conseguenza. Il kernel Linux è inciampato su questo ( https://web.nvd.nist.gov/view/vuln/detail?vulnId=CVE-2009-1897 ) essenzialmente perché funziona in una modalità in cui non si suppone che dereferenziare un puntatore nullo essere UB, dovrebbe risultare in un'eccezione hardware definita che il kernel può gestire. Quando l'ottimizzazione è abilitata, gcc richiede l'uso di -fno-delete-null-pointer-checks
per fornire quella garanzia oltre gli standard.
PS La risposta pratica alla domanda "quando colpisce un comportamento indefinito?" è "10 minuti prima di partire per la giornata".