Ordine di valutazione degli indici di array (rispetto all'espressione) in C


47

Guardando questo codice:

static int global_var = 0;

int update_three(int val)
{
    global_var = val;
    return 3;
}

int main()
{
    int arr[5];
    arr[global_var] = update_three(2);
}

Quale voce dell'array viene aggiornata? 0 o 2?

C'è una parte nella specifica di C che indica la precedenza dell'operazione in questo caso particolare?


21
Questo odora di comportamento indefinito. È certamente qualcosa che non dovrebbe mai essere codificato di proposito.
Fiddling Bits

1
Sono d'accordo che sia un esempio di cattiva codifica.
Jiminion

4
Alcuni risultati aneddotici: godbolt.org/z/hM2Jo2
Bob__

15
Ciò non ha nulla a che fare con gli indici di array o l'ordine delle operazioni. Ha a che fare con ciò che la specifica C chiama "punti di sequenza", e in particolare il fatto che le espressioni di assegnazione NON creano un punto di sequenza tra l'espressione per la mano sinistra e per la mano destra, quindi il compilatore è libero di fare come sceglie.
Lee Daniel Crocker,

4
È necessario segnalare una richiesta di funzione in clangmodo che questo pezzo di codice attivi un avviso IMHO.
malat

Risposte:


51

Ordine degli operandi sinistro e destro

Per eseguire l'assegnazione arr[global_var] = update_three(2), l'implementazione C deve valutare gli operandi e, come effetto collaterale, aggiornare il valore memorizzato dell'operando di sinistra. C 2018 6.5.16 (che riguarda le assegnazioni) il paragrafo 3 ci dice che non c'è sequenziamento negli operandi sinistro e destro:

Le valutazioni degli operandi non sono seguite.

Ciò significa che l'implementazione in C è libera di calcolare prima il lvalue arr[global_var] (calcolando il lvalue, intendiamo capire a cosa si riferisce questa espressione), quindi valutare update_three(2), e infine assegnare il valore di quest'ultimo al primo; oppure per update_three(2)prima valutare , quindi calcolare il valore, quindi assegnare il primo al secondo; o per valutare il lvalue e update_three(2)in qualche modo mescolato e quindi assegnare il valore giusto al lvalue sinistro.

In tutti i casi, l'assegnazione del valore al valore deve arrivare per ultima, perché 6.5.16 3 dice anche:

... L'effetto collaterale dell'aggiornamento del valore memorizzato dell'operando di sinistra viene sequenziato dopo i calcoli del valore degli operandi di sinistra e di destra ...

Violazione del sequenziamento

Alcuni potrebbero meditare sul comportamento indefinito a causa sia dell'utilizzo global_varche dell'aggiornamento separato in violazione di 6.5 2, che dice:

Se un effetto collaterale su un oggetto scalare non è seguito rispetto a un diverso effetto collaterale sullo stesso oggetto scalare o a un calcolo del valore che utilizza il valore dello stesso oggetto scalare, il comportamento non è definito ...

È abbastanza familiare a molti professionisti C che il comportamento di espressioni come x + x++non è definito dallo standard C perché entrambi usano il valore xe lo modificano separatamente nella stessa espressione senza sequenziamento. Tuttavia, in questo caso, abbiamo una chiamata di funzione, che fornisce alcune sequenze. global_varviene utilizzato in arr[global_var]e viene aggiornato nella chiamata di funzione update_three(2).

6.5.2.2 10 ci dice che c'è un punto sequenza prima che la funzione venga chiamata:

Esiste un punto sequenza dopo le valutazioni del designatore della funzione e degli argomenti effettivi, ma prima della chiamata effettiva ...

All'interno della funzione, global_var = val;c'è un'espressione completa , così come 3in return 3;, per 6.8 4:

Una piena espressione è un'espressione che non fa parte del un'altra espressione, né parte di un dichiaratore o dichiaratore astratto ...

Quindi c'è un punto sequenza tra queste due espressioni, sempre per 6,8 4:

… Esiste un punto di sequenza tra la valutazione di un'espressione completa e la valutazione della successiva espressione completa da valutare.

Pertanto, l'implementazione C può valutare arr[global_var]prima e quindi eseguire la chiamata di funzione, nel qual caso c'è un punto di sequenza tra loro perché ce n'è uno prima della chiamata di funzione, oppure può valutare global_var = val;nella chiamata di funzione e quindi arr[global_var], nel qual caso c'è un punto di sequenza tra loro perché ce n'è uno dopo l'espressione completa. Quindi il comportamento non è specificato - una di queste due cose può essere valutata per prima - ma non è indefinita.


24

Il risultato qui non è specificato .

Mentre l'ordine delle operazioni in un'espressione, che determina come sono raggruppate le sottoespressioni, è ben definito, l'ordine di valutazione non è specificato. In questo caso significa che o global_varpotrebbe essere letto per primo o la chiamata a update_threepotrebbe avvenire per prima, ma non c'è modo di sapere quale.

C'è non un comportamento indefinito qui perché una chiamata di funzione introduce un punto di sequenza, come fa ogni affermazione nella funzione tra cui quella che modifica global_var.

Per chiarire, lo standard C. definisce il comportamento indefinito nella sezione 3.4.3 come:

comportamento indefinito

comportamento, in caso di utilizzo di un costrutto di programma non portabile o errato o di dati errati, per i quali la presente norma internazionale non impone requisiti

e definisce un comportamento non specificato nella sezione 3.4.4 come:

comportamento non specificato

utilizzo di un valore non specificato o altro comportamento in cui la presente norma internazionale offre due o più possibilità e non impone ulteriori requisiti su quale sia scelta in ogni caso

Lo standard afferma che l'ordine di valutazione degli argomenti delle funzioni non è specificato, il che significa che arr[0]viene impostato su 3 o arr[2]impostato su 3.


"Una chiamata di funzione introduce un punto sequenza" non è sufficiente. Se l'operando di sinistra viene valutato per primo, è sufficiente, da allora il punto di sequenza separa l'operando di sinistra dalle valutazioni nella funzione. Tuttavia, se l'operando di sinistra viene valutato dopo la chiamata della funzione, il punto di sequenza dovuto alla chiamata della funzione non è tra le valutazioni nella funzione e la valutazione dell'operando di sinistra. È inoltre necessario il punto di sequenza che separa le espressioni complete.
Eric Postpischil,

2
@EricPostpischil Nella terminologia pre-C11 c'è un punto sequenza all'ingresso e all'uscita di una funzione. Nella terminologia C11 l'intero corpo della funzione è indeterminato in sequenza rispetto al contesto chiamante. Entrambi specificano la stessa cosa, usando solo termini diversi
MM

Questo è assolutamente sbagliato. L'ordine di valutazione degli argomenti dell'incarico non è specificato. Per quanto riguarda il risultato di questo particolare incarico, si tratta della creazione di un array con un contenuto inaffidabile, sia non portabile che intrinsecamente sbagliato (incompatibile con la semantica o uno dei risultati previsti). Un caso perfetto di comportamento indefinito.
Kuroi Neko,

1
@kuroineko Solo perché l'output può variare non rende automaticamente un comportamento indefinito. Lo standard ha definizioni diverse per comportamento indefinito vs. non specificato, e in questa situazione è quest'ultimo.
Arriva il

@EricPostpischil Qui hai punti di sequenza (dall'allegato informativo C11 C): "Tra le valutazioni del designatore della funzione e gli argomenti reali in una chiamata di funzione e la chiamata effettiva. (6.5.2.2)", "Tra la valutazione di un'espressione completa e la prossima espressione completa da valutare ... / - / ... l'espressione (facoltativa) in un'istruzione return (6.8.6.4) ". E bene, anche ad ogni punto e virgola, poiché questa è un'espressione completa.
Lundin,

1

Ho provato e ho aggiornato la voce 0.

Comunque secondo questa domanda: sarà il lato destro di un'espressione sempre valutata per prima

L'ordine di valutazione non è specificato e non è seguito. Quindi penso che un codice come questo dovrebbe essere evitato.


Ho ricevuto anche l'aggiornamento alla voce 0.
Jiminion

1
Il comportamento non è indefinito ma non specificato. Naturalmente a seconda di entrambi dovrebbe essere evitato.
Antti Haapala,

@AnttiHaapala Ho modificato
Mickael B.

1
Hmm ah e non è senza conseguenze, ma in sequenza indeterminata ... 2 persone in piedi casualmente in una coda sono in sequenza indeterminata. Neo all'interno di Agent Smith non ci sono conseguenze e si verificherà un comportamento indefinito.
Antti Haapala,

0

Poiché ha poco senso emettere codice per un compito prima di avere un valore da assegnare, la maggior parte dei compilatori C emetterà prima il codice che chiama la funzione e salverà il risultato da qualche parte (registro, stack, ecc.), Quindi emetterà codice che scrive questo valore nella sua destinazione finale e pertanto leggeranno la variabile globale dopo che è stata modificata. Chiamiamo questo "ordine naturale", non definito da alcuno standard ma dalla pura logica.

Tuttavia, nel processo di ottimizzazione, i compilatori cercheranno di eliminare il passaggio intermedio della memorizzazione temporanea del valore da qualche parte e tenteranno di scrivere il risultato della funzione il più direttamente possibile sulla destinazione finale e in tal caso, dovranno spesso leggere prima l'indice , ad esempio in un registro, per poter spostare direttamente il risultato della funzione nell'array. Ciò potrebbe causare la lettura della variabile globale prima che fosse cambiata.

Quindi questo è sostanzialmente un comportamento indefinito con la pessima proprietà che è abbastanza probabile che il risultato sarà diverso, a seconda se l'ottimizzazione viene eseguita e quanto aggressiva sia questa ottimizzazione. Il tuo compito come sviluppatore è quello di risolvere il problema tramite la codifica:

int idx = global_var;
arr[idx] = update_three(2);

o codifica:

int temp = update_three(2);
arr[global_var] = temp;

Come buona regola empirica: a meno che le variabili globali non lo siano const(o non lo siano, ma sai che nessun codice le modificherà mai come effetto collaterale), non dovresti mai usarle direttamente nel codice, come in un ambiente multi-thread, anche questo può essere indefinito:

int result = global_var + (2 * global_var);
// Is not guaranteed to be equal to `3 * global_var`!

Poiché il compilatore può leggerlo due volte e un altro thread può modificare il valore tra le due letture. Ancora una volta, l'ottimizzazione indurrebbe sicuramente il codice a leggerlo solo una volta, quindi potresti avere di nuovo risultati diversi che ora dipendono anche dai tempi di un altro thread. Quindi avrai molto meno mal di testa se memorizzi le variabili globali in una variabile di stack temporanea prima dell'uso. Tieni presente che se il compilatore ritiene che sia sicuro, molto probabilmente ottimizzerà anche quello e utilizzerà invece direttamente la variabile globale, quindi alla fine potrebbe non fare alcuna differenza in termini di prestazioni o utilizzo della memoria.

(Nel caso in cui qualcuno si chieda perché qualcuno dovrebbe fare x + 2 * xinvece di 3 * x- su alcune CPU l'aggiunta è ultraveloce e lo è anche la moltiplicazione per una potenza due poiché il compilatore li trasformerà in bit shift ( 2 * x == x << 1), ma la moltiplicazione con numeri arbitrari può essere molto lenta , quindi invece di moltiplicare per 3, ottieni un codice molto più veloce spostando bit di x di 1 e aggiungendo x al risultato - e anche quel trucco viene eseguito dai compilatori moderni se moltiplichi per 3 e attivi l'ottimizzazione aggressiva a meno che non sia un obiettivo moderno CPU in cui la moltiplicazione è altrettanto veloce come aggiunta da allora il trucco rallenterebbe il calcolo.)


2
Non è un comportamento indefinito - lo standard elenca le possibilità e una di queste è scelta in ogni caso
Antti Haapala

Il compilatore non si trasformerà 3 * xin due letture di x. Potrebbe leggere x una volta e quindi eseguire il metodo x + 2 * x sul registro in cui ha letto x
MM

6
@Mecki "Se non riesci a dire quale sia il risultato semplicemente guardando il codice, il risultato è indefinito" - il comportamento indefinito ha un significato molto specifico in C / C ++, e non è così. Altri risponditori hanno spiegato perché questa particolare istanza non è specificata , ma non è definita .
marzo

3
Apprezzo l'intento di gettare un po 'di luce negli interni di un computer, anche se questo va oltre lo scopo della domanda originale. Tuttavia, UB è un gergo C / C ++ molto preciso e dovrebbe essere usato con attenzione, specialmente quando la domanda riguarda un tecnicismo linguistico. Si potrebbe prendere in considerazione l'utilizzo del termine "comportamento non specificato" corretto, che migliorerebbe significativamente la risposta.
Kuroi Neko,

2
@Mecki " non definita ha un significato molto speciale in lingua inglese " ... ma in una domanda etichettato language-lawyer, in cui la lingua in questione ha la sua propria "significato molto speciale" per undefined , si sta andando solo a creare confusione non usando la definizione della lingua.
TripeHound il

-1

Modifica globale: scusate ragazzi, sono stato tutto eccitato e ho scritto molte sciocchezze. Solo un vecchio geezer ranting.

Volevo credere che C fosse stato risparmiato, ma purtroppo dal C11 è stato portato alla pari con il C ++. Apparentemente, sapere cosa farà il compilatore con gli effetti collaterali nelle espressioni richiede ora di risolvere un piccolo enigma matematico che coinvolge un ordinamento parziale delle sequenze di codici basato su un "si trova prima del punto di sincronizzazione di".

Mi è capitato di aver progettato e implementato alcuni sistemi critici integrati in tempo reale nei giorni di K&R (incluso il controller di un'auto elettrica che potrebbe mandare le persone a schiantarsi contro la parete più vicina se il motore non fosse tenuto sotto controllo, un industriale di 10 tonnellate robot che potrebbe schiacciare le persone in una polpa se non correttamente comandato, e un livello di sistema che, sebbene innocuo, avrebbe alcune decine di processori che risucchiano il loro bus dati a secco con meno dell'1% di sovraccarico del sistema).

Potrei essere troppo senile o stupido per ottenere la differenza tra indefinito e non specificato, ma penso di avere ancora una buona idea di cosa significhino l'esecuzione simultanea e l'accesso ai dati. Secondo la mia opinione probabilmente informata, questa ossessione per il C ++ e ora i ragazzi C con i loro linguaggi da compagnia che si occupano di problemi di sincronizzazione è un sogno costoso. O sai cos'è l'esecuzione simultanea, e non hai bisogno di nessuno di questi aggeggi, oppure non lo fai, e faresti un favore al mondo in generale senza cercare di rovinarlo.

Tutto questo carico di astrazioni di barriera di memoria allettante è semplicemente dovuto a una serie temporanea di limiti dei sistemi cache multi-CPU, che possono essere incapsulati in modo sicuro in oggetti di sincronizzazione del sistema operativo comuni come, ad esempio, i mutex e le variabili di condizione C ++ offerte.
Il costo di questo incapsulamento è solo un piccolo calo delle prestazioni rispetto a ciò che in alcuni casi potrebbe essere utile un uso di istruzioni specifiche della CPU a grana fine.
La volatileparola chiave (o a#pragma dont-mess-with-that-variableper quanto mi occupassi, come programmatore di sistema, sarei stato abbastanza per dire al compilatore di smettere di riordinare gli accessi alla memoria. Il codice ottimale può essere facilmente prodotto con direttive ASM dirette per cospargere driver di basso livello e codice OS con istruzioni specifiche della CPU ad hoc. Senza una conoscenza approfondita del funzionamento dell'hardware sottostante (sistema cache o interfaccia bus), sei comunque obbligato a scrivere codice inutile, inefficiente o difettoso.

Un minuto di aggiustamento della volatileparola chiave e Bob sarebbero stati tutti, tranne lo zio dei programmatori di basso livello più sodo. Invece, la solita banda di maniaci della matematica in C ++ ha avuto una giornata campale progettando l'ennesima incomprensibile astrazione, cedendo alla loro tipica tendenza a progettare soluzioni alla ricerca di problemi inesistenti e confondendo la definizione di un linguaggio di programmazione con le specifiche di un compilatore.

Solo che questa volta il cambiamento ha richiesto di deturpare anche un aspetto fondamentale di C, poiché queste "barriere" dovevano essere generate anche nel codice C di basso livello per funzionare correttamente. Ciò, tra le altre cose, ha provocato il caos nella definizione delle espressioni, senza alcuna spiegazione o giustificazione.

In conclusione, il fatto che un compilatore possa produrre un codice macchina coerente da questo assurdo pezzo di C è solo una lontana conseguenza del modo in cui i ragazzi di C ++ hanno affrontato potenziali incoerenze dei sistemi di cache alla fine degli anni 2000.
Ha creato un terribile pasticcio di un aspetto fondamentale di C (definizione di espressione), così che la stragrande maggioranza dei programmatori C - che non se ne frega niente dei sistemi cache, e giustamente - è ora costretta a fare affidamento sui guru per spiegare differenza tra a = b() + c()e a = b + c.

Cercare di indovinare cosa ne sarà di questa sfortunata schiera è comunque una perdita netta di tempo e sforzi. Indipendentemente da cosa ne farà il compilatore, questo codice è patologicamente sbagliato. L'unica cosa responsabile a che fare con esso è inviarlo al cestino.
Concettualmente, gli effetti collaterali possono sempre essere spostati dalle espressioni, con il banale sforzo di lasciare esplicitamente la modifica prima o dopo la valutazione, in una dichiarazione separata.
Questo tipo di codice di merda potrebbe essere stato giustificato negli anni '80, quando non ci si poteva aspettare che un compilatore ottimizzasse qualcosa. Ma ora che i compilatori sono diventati a lungo più intelligenti della maggior parte dei programmatori, tutto ciò che rimane è un pezzo di codice di merda.

Non riesco anche a capire l'importanza di questo dibattito indefinito / non specificato. O puoi fare affidamento sul compilatore per generare codice con un comportamento coerente o non puoi. Se lo chiami indefinito o non specificato sembra un punto controverso.

Secondo la mia opinione probabilmente informata, C è già abbastanza pericoloso nel suo stato di K&R. Un'evoluzione utile sarebbe quella di aggiungere misure di sicurezza di buon senso. Ad esempio, facendo uso di questo strumento avanzato di analisi del codice, le specifiche costringono il compilatore a implementare almeno per generare avvisi sul codice bonkers, invece di generare silenziosamente un codice potenzialmente inaffidabile all'estremo.
Ma invece i ragazzi hanno deciso, ad esempio, di definire un ordine di valutazione fisso in C ++ 17. Ora ogni software imbecille è attivamente incitato a mettere di proposito gli effetti collaterali nel suo codice, crogiolandosi nella certezza che i nuovi compilatori gestiranno con entusiasmo l'offuscamento in modo deterministico.

K&R è stata una delle vere meraviglie del mondo dell'informatica. Per venti dollari hai ottenuto una specifica completa della lingua (ho visto singoli individui scrivere compilatori completi solo usando questo libro), un eccellente manuale di riferimento (il sommario di solito ti indicherà un paio di pagine della risposta alla tua domanda) e un libro di testo che ti insegnerebbe ad usare la lingua in modo sensato. Completa di razionali, esempi e sagge parole di avvertimento sui numerosi modi in cui potresti abusare della lingua per fare cose molto, molto stupide.

Distruggere quell'eredità per così poco guadagno mi sembra uno spreco crudele. Ma di nuovo potrei benissimo non riuscire a vedere il punto completamente. Forse un'anima gentile potrebbe indirizzarmi verso un esempio di nuovo codice C che sfrutta in modo significativo questi effetti collaterali?


È un comportamento indefinito se ci sono effetti collaterali sullo stesso oggetto nella stessa espressione, C17 6.5 / 2. Non sono previsti come da C17 6.5.18 / 3. Ma il testo da 6.5 / 2 "Se un effetto collaterale su un oggetto scalare non è seguito rispetto a un diverso effetto collaterale sullo stesso oggetto scalare o ad un calcolo del valore usando il valore dello stesso oggetto scalare, il comportamento non è definito." non si applica, poiché il calcolo del valore all'interno della funzione viene eseguito in sequenza prima o dopo l'accesso all'indice dell'array, indipendentemente dal fatto che l'operatore di assegnazione abbia operandi non seguiti in sé.
Lundin,

La chiamata di funzione si comporta in un certo modo come "un mutex contro l'accesso non seguito", se lo desideri. Simile all'oscuro operatore di virgole che fa schifo 0,expr,0.
Lundin,

Penso che tu abbia creduto agli autori dello Standard quando hanno detto "Il comportamento indefinito concede all'implementatore la licenza di non rilevare alcuni errori del programma che sono difficili da diagnosticare. Identifica anche aree di estensione della lingua conforme: l'implementatore può aumentare la lingua fornendo un definizione del comportamento ufficialmente indefinito . " e disse che lo Standard non avrebbe dovuto sminuire programmi utili che non erano strettamente conformi. Penso che la maggior parte degli autori dello standard avrebbe ritenuto ovvio che le persone che cercano di scrivere compilatori di qualità ...
supercat

... dovrebbero cercare di utilizzare UB come un'opportunità per rendere i loro compilatori il più utili possibile per i loro clienti. Dubito che qualcuno avrebbe potuto immaginare che gli autori di compilatori lo avrebbero usato come scusa per rispondere alle lamentele di "Il tuo compilatore elabora questo codice in modo meno utile rispetto a quello di tutti gli altri" con "Questo perché lo Standard non ci richiede di elaborarlo in modo utile e implementazioni che elabora utilmente programmi il cui comportamento non è prescritto dalla norma promuove semplicemente la scrittura di programmi non funzionanti ".
supercat

Non riesco a vedere il punto nella tua osservazione. Affidarsi a comportamenti specifici del compilatore è una garanzia di non portabilità. Richiede inoltre grande fiducia nel produttore del compilatore, che potrebbe interrompere qualsiasi di queste "definizioni extra" in qualsiasi momento. L'unica cosa che un compilatore può fare è generare avvisi, che un programmatore saggio e ben informato potrebbe decidere di gestire simili errori. Il problema che vedo con questo mostro ISO è che rende legittimo un codice così atroce come l'esempio del PO (per ragioni estremamente poco chiare, rispetto alla definizione K&R di un'espressione).
Kuroi Neko,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.