Perché il C ++ ha un "comportamento indefinito" (UB) e altri linguaggi come C # o Java no?


50

Questo post Stack Overflow elenca un elenco abbastanza completo di situazioni in cui le specifiche del linguaggio C / C ++ dichiarano come "comportamento indefinito". Tuttavia, voglio capire perché altri linguaggi moderni, come C # o Java, non hanno il concetto di "comportamento indefinito". Significa che il progettista del compilatore può controllare tutti gli scenari possibili (C # e Java) o meno (C e C ++)?




3
eppure questo post SO fa riferimento a comportamenti indefiniti anche nelle specifiche Java!
gbjbaanb,

"Perché il C ++ ha un" comportamento indefinito "" Sfortunatamente, questa sembra essere una di quelle domande a cui è difficile rispondere obiettivamente, oltre l'affermazione "perché, per ragioni X, Y e / o Z (che potrebbero essere tutte nullptr) no si è preso la briga di definire il comportamento scrivendo e / o adottando una specifica proposta ". : c
code_dredd

Sfiderei la premessa. Almeno C # ha un codice "non sicuro". Microsoft scrive "In un certo senso, scrivere codice non sicuro è molto simile a scrivere codice C all'interno di un programma C #" e fornisce esempi di motivi per cui si vorrebbe farlo: per accedere all'hardware o al sistema operativo e per la velocità. Questo è ciò per cui C è stato inventato (diavolo, hanno scritto il sistema operativo in C!), Quindi il gioco è fatto.
Peter - Ripristina Monica il

Risposte:


72

Il comportamento indefinito è una di quelle cose che sono state riconosciute come una pessima idea solo a posteriori.

I primi compilatori sono stati grandi risultati e hanno accolto con gioia i miglioramenti rispetto all'alternativa - programmazione del linguaggio macchina o linguaggio assembly. I problemi con questo erano ben noti e le lingue di alto livello furono inventate appositamente per risolvere quei problemi noti. (L'entusiasmo all'epoca era così grande che a volte le HLL erano salutate come "la fine della programmazione" - come se d'ora in poi dovessimo solo scrivere banalmente ciò che volevamo e il compilatore avrebbe fatto tutto il vero lavoro.)

Non è stato fino a dopo che ci siamo resi conto dei nuovi problemi che derivavano dal nuovo approccio. Essere remoti dalla macchina reale su cui viene eseguito il codice significa che c'è più possibilità che le cose silenziosamente non facciano ciò che ci aspettavamo che facessero. Ad esempio, l'allocazione di una variabile in genere lascerebbe indefinito il valore iniziale; questo non è stato considerato un problema, perché non assegneresti una variabile se non volessi conservare un valore, giusto? Sicuramente non era troppo aspettarsi che i programmatori professionisti non dimenticassero di assegnare il valore iniziale, vero?

Si è scoperto che con le basi di codice più grandi e le strutture più complicate che sono diventate possibili con sistemi di programmazione più potenti, sì, molti programmatori avrebbero effettivamente commesso tali sviste di volta in volta, e il conseguente comportamento indefinito è diventato un grave problema. Ancora oggi, la maggior parte delle perdite di sicurezza da minuscole a orribili sono il risultato di comportamenti indefiniti in una forma o nell'altra. (La ragione è che di solito il comportamento indefinito è in realtà molto definito dalle cose al livello inferiore successivo sull'informatica, e gli attaccanti che capiscono quel livello possono usare quel margine di manovra per creare un programma non solo cose indesiderate, ma esattamente le cose essi intendono.)

Da quando l'abbiamo riconosciuto, c'è stato un impulso generale a bandire comportamenti indefiniti da linguaggi di alto livello, e Java è stato particolarmente accurato al riguardo (il che è stato relativamente facile dal momento che è stato progettato per funzionare sulla propria macchina virtuale appositamente progettata). Le lingue meno recenti come C non possono essere facilmente adattate in questo modo senza perdere la compatibilità con l'enorme quantità di codice esistente.

Modifica: come sottolineato, l'efficienza è un'altra ragione. Comportamento indefinito significa che gli autori di compilatori hanno molto margine di manovra per sfruttare l'architettura di destinazione in modo che ogni implementazione riesca a implementare più rapidamente possibile di ogni funzionalità. Ciò era più importante sulle macchine poco potenti di ieri rispetto a oggi, quando lo stipendio del programmatore è spesso il collo di bottiglia per lo sviluppo del software.


56
Non penso che molte persone della comunità C sarebbero d'accordo con questa affermazione. Se si adeguasse C e si definisse un comportamento indefinito (ad es. Inizializzazione predefinita di tutto, scelta di un ordine di valutazione per parametro di funzione, ecc.), L'ampia base di codice ben educato continuerebbe a funzionare perfettamente. Solo il codice che non sarebbe ben definito oggi verrebbe interrotto. D'altra parte, se si lascia indefinito come oggi, i compilatori continuerebbero a essere liberi di sfruttare i nuovi progressi nelle architetture della CPU e nell'ottimizzazione del codice.
Christophe,

13
La parte principale della risposta non sembra davvero convincente per me. Voglio dire, è praticamente impossibile scrivere una funzione che aggiunge in modo sicuro due numeri (come in int32_t add(int32_t x, int32_t y)) in C ++. I soliti argomenti intorno a quello sono legati all'efficienza, ma spesso intervallati da alcuni argomenti di portabilità (come in "Scrivi una volta, esegui ... sulla piattaforma in cui l'hai scritta ... e da nessun'altra parte ;-)"). In
parole povere

12
@ Marco13 Concordato - e sbarazzarsi del problema del "comportamento indefinito" creando qualcosa di "comportamento definito", ma non necessariamente quello che l'utente voleva e senza preavviso quando si verifica "invece del" comportamento indefinito "è solo giocare a giochi di avvocato di codice IMO .
alephzero,

9
"Ancora oggi, la maggior parte delle perdite di sicurezza da minuscole a orribili sono il risultato di comportamenti indefiniti in una forma o nell'altra." Citazione necessaria. Pensavo che la maggior parte di loro fosse un'iniezione di XYZ ora.
Giosuè il

34
"Il comportamento indefinito è una di quelle cose che sono state riconosciute come una pessima idea solo a posteriori." Questa è la tua opinione. Molti (me compreso) non lo condividono.
Corse di leggerezza con Monica il

103

Fondamentalmente perché i progettisti di Java e di linguaggi simili non volevano comportamenti indefiniti nella loro lingua. Questo è stato un compromesso: consentire comportamenti indefiniti ha il potenziale per migliorare le prestazioni, ma i progettisti del linguaggio hanno dato priorità alla sicurezza e alla prevedibilità più elevate.

Ad esempio, se si assegna una matrice in C, i dati non sono definiti. In Java, tutti i byte devono essere inizializzati su 0 (o su qualche altro valore specificato). Ciò significa che il runtime deve passare sull'array (un'operazione O (n)), mentre C può eseguire l'allocazione in un istante. Quindi C sarà sempre più veloce per tali operazioni.

Se il codice che utilizza l'array lo popolerà comunque prima di leggere, questo è sostanzialmente uno sforzo sprecato per Java. Ma nel caso in cui il codice venga letto per primo, si ottengono risultati prevedibili in Java ma risultati imprevedibili in C.


19
Eccellente presentazione del dilemma HLL: sicurezza e facilità d'uso rispetto alle prestazioni. Non esiste un proiettile d'argento: ci sono casi d'uso per ogni lato.
Christophe,

5
@Christophe Per essere onesti, ci sono approcci molto migliori a un problema rispetto a lasciare UB totalmente incontestato come C e C ++. Potresti avere un linguaggio sicuro e gestito, con tratteggi di fuga in un territorio non sicuro, da applicare laddove utile. TBH, sarebbe davvero bello essere in grado di compilare il mio programma C / C ++ con un flag che dice "inserisci qualsiasi macchina di runtime costosa di cui hai bisogno, non mi interessa, ma parlami di TUTTO l'UB che si verifica ".
Alexander,

4
Un buon esempio di una struttura di dati che legge deliberatamente posizioni non inizializzate è la rappresentazione sparsa di insiemi di Briggs e Torczon (ad es. Vedi codingplayground.blogspot.com/2009/03/… ) L'inizializzazione di un tale insieme è O (1) in C, ma O ( n) con l'inizializzazione forzata di Java.
Arch D. Robison,

9
Mentre è vero che forzare l'inizializzazione dei dati rende i programmi interrotti molto più prevedibili, non garantisce il comportamento previsto: se l'algoritmo prevede di leggere dati significativi mentre legge erroneamente lo zero inizializzato implicitamente, è un bug tanto quanto se avesse leggi un po 'di immondizia. Con un programma C / C ++ un tale bug sarebbe visibile eseguendo il processo sotto valgrind, che mostrerebbe esattamente dove veniva usato il valore non inizializzato. Non è possibile utilizzare valgrindil codice Java perché il runtime esegue l'inizializzazione, rendendo valgrindinutili i controlli.
cmaster

5
@cmaster Ecco perché il compilatore C # non ti consente di leggere da gente del posto non inizializzata. Nessuna necessità di controlli di runtime, nessuna necessità di inizializzazione, solo analisi in fase di compilazione. È ancora un compromesso, tuttavia - ci sono alcuni casi in cui non hai un buon modo per gestire la ramificazione intorno a persone potenzialmente non assegnate. In pratica, non ho trovato casi in cui questo non fosse un cattivo progetto in primo luogo e meglio risolto ripensando il codice per evitare la ramificazione complicata (che è difficile da analizzare per gli umani), ma almeno è possibile.
Luaan,

42

Il comportamento indefinito consente un'ottimizzazione significativa, dando al compilatore la latitudine di fare qualcosa di strano o inaspettato (o persino normale) a determinati confini o altre condizioni.

Vedi http://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html

Uso di una variabile non inizializzata: questa è comunemente nota come fonte di problemi nei programmi C e ci sono molti strumenti per catturarli: dagli avvisi del compilatore agli analizzatori statici e dinamici. Ciò migliora le prestazioni non richiedendo l'inizializzazione di tutte le variabili quando entrano nell'ambito (come fa Java). Per la maggior parte delle variabili scalari, ciò comporterebbe un piccolo sovraccarico, ma gli array di stack e la memoria malloc incorrerebbero in un memset dell'archiviazione, che potrebbe essere piuttosto costoso, soprattutto perché l'archiviazione è di solito completamente sovrascritta.


Overflow intero con segno: se l'aritmetica su un tipo 'int' (ad esempio) trabocca, il risultato non è definito. Un esempio è che "INT_MAX + 1" non è garantito per essere INT_MIN. Questo comportamento consente alcune classi di ottimizzazioni che sono importanti per alcuni codici. Ad esempio, sapere che INT_MAX + 1 non è definito consente di ottimizzare "X + 1> X" su "vero". Conoscere la moltiplicazione "impossibile" overflow (perché non sarebbe definito) consentire l'ottimizzazione di "X * 2/2" su "X". Mentre questi possono sembrare banali, questo genere di cose sono comunemente esposte dall'espansione e dall'espansione macro. Un'ottimizzazione più importante che ciò consente è per i cicli "<=" come questo:

for (i = 0; i <= N; ++i) { ... }

In questo ciclo, il compilatore può presumere che il ciclo ripeterà esattamente N + 1 volte se "i" non è definito su overflow, il che consente di avviare un'ampia gamma di ottimizzazioni del ciclo. D'altra parte, se la variabile è definita in andare a capo di un overflow, quindi il compilatore deve presumere che il ciclo sia probabilmente infinito (cosa che succede se N è INT_MAX) - che quindi disabilita queste importanti ottimizzazioni del ciclo. Ciò riguarda in particolare le piattaforme a 64 bit poiché tanto codice utilizza "int" come variabili di induzione.


27
Ovviamente, il vero motivo per cui l'overflow di numeri interi con segno è indefinito è che quando è stato sviluppato C, c'erano almeno tre diverse rappresentazioni di numeri interi con segno (complemento a uno, complemento a due, magnitudine del segno e forse binario di offset) e ognuno dà un risultato diverso per INT_MAX + 1. Rendere indefinito l'overflow consente a + bdi compilare l' add b aistruzione nativa in ogni situazione, piuttosto che richiedere potenzialmente un compilatore per simulare qualche altra forma di aritmetica intera con segno.
Segna il

2
Consentire agli overflow di numeri interi di comportarsi in modo vagamente definito consente ottimizzazioni significative nei casi in cui tutti i comportamenti possibili soddisfino i requisiti dell'applicazione . La maggior parte di queste ottimizzazioni andrà persa, tuttavia, se i programmatori sono tenuti ad evitare traboccamenti di numeri interi a tutti i costi.
Supercat,

5
@supercat Questo è un altro motivo per cui evitare comportamenti indefiniti è più comune nelle lingue più recenti: il tempo del programmatore è valutato molto più del tempo della CPU. Il tipo di ottimizzazioni che C può fare grazie a UB è essenzialmente inutile sui moderni computer desktop e rende molto più difficile il ragionamento sul codice (per non parlare delle implicazioni sulla sicurezza). Anche nel codice critico per le prestazioni, puoi trarre vantaggio da ottimizzazioni di alto livello che sarebbero un po 'più difficili (o anche molto più difficili) da fare in C. Ho il mio software 3D renderer in C # e poter usare ad esempio a HashSetè meraviglioso.
Luaan,

2
@supercat: Wrt_loosely definito_, la scelta logica per l'overflow di numeri interi sarebbe richiedere il comportamento definito dall'implementazione. Questo è un concetto esistente e non è un onere eccessivo per le implementazioni. La maggior parte vorrebbe cavarsela con "è il complemento di 2 con avvolgente", sospetto. <<potrebbe essere il caso difficile.
MSalters il

@MSalters Esiste una soluzione semplice e ben studiata che non è né un comportamento indefinito né un comportamento definito dall'implementazione: comportamento non deterministico. Cioè, puoi dire " x << yvaluta un valore valido del tipo int32_tma non diremo quale". Ciò consente agli implementatori di utilizzare la soluzione rapida, ma non funge da falsa precondizione consentendo l'ottimizzazione dello stile del viaggio nel tempo perché il non determinismo è vincolato all'output di questa operazione - le specifiche garantiscono che la memoria, le variabili volatili, ecc. Non siano visibilmente influenzate dalla valutazione dell'espressione. ...
Mario Carneiro,

20

All'inizio C, c'era molto caos. Diversi compilatori hanno trattato la lingua in modo diverso. Quando c'era interesse a scrivere una specifica per il linguaggio, quella specifica avrebbe dovuto essere abbastanza retrocompatibile con la C su cui i programmatori si affidavano ai loro compilatori. Ma alcuni di questi dettagli non sono portatili e non hanno senso in generale, ad esempio assumendo una particolare endianess o layout di dati. Lo standard C pertanto riserva molti dettagli come comportamento indefinito o specificato dall'implementazione, che lascia molta flessibilità agli autori di compilatori. C ++ si basa su C e presenta anche un comportamento indefinito.

Java ha cercato di essere un linguaggio molto più sicuro e molto più semplice di C ++. Java definisce la semantica del linguaggio in termini di una macchina virtuale completa. Ciò lascia poco spazio a comportamenti indefiniti, d'altra parte rende requisiti che possono essere difficili da realizzare per un'implementazione Java (ad es. Che le assegnazioni di riferimento devono essere atomiche o come funzionano gli interi). Laddove Java supporta operazioni potenzialmente non sicure, in genere vengono controllate dalla macchina virtuale in fase di esecuzione (ad esempio, alcuni cast).


Quindi stai dicendo che la retrocompatibilità è l'unica ragione per cui C e C ++ non escono da comportamenti indefiniti?
Sisir,

3
È sicuramente uno dei più grandi, @Sisir. Anche tra i programmatori esperti, rimarrai sorpreso di quanto cose che non dovrebbero essere interrotte si rompono quando un compilatore cambia il modo in cui gestisce il comportamento indefinito. (Caso in questione, c'è stato un po 'di caos quando GCC ha iniziato a ottimizzare i controlli "is thisnull?" Qualche tempo fa, per il fatto che thisessere nullptrUB, e quindi non può mai realmente accadere.)
Justin Time 2 Ripristina Monica

9
@Sisir, un altro grande è la velocità. All'inizio C, l'hardware era molto più eterogeneo di oggi. Semplicemente non specificando cosa succede quando si aggiunge 1 a INT_MAX, è possibile lasciare che il compilatore faccia tutto ciò che è più veloce per l'architettura (es. Un sistema a complemento individuale produrrà -INT_MAX, mentre un sistema a complemento a due produrrà INT_MIN). Allo stesso modo, non specificando cosa succede quando leggi oltre la fine di un array, puoi avere un sistema con protezione della memoria che termina il programma, mentre uno senza non sarà necessario implementare costosi limiti di runtime.
Segna il

14

I linguaggi JVM e .NET semplificano:

  1. Non devono essere in grado di lavorare direttamente con l'hardware.
  2. Devono solo funzionare con moderni sistemi desktop e server o dispositivi ragionevolmente simili, o almeno dispositivi progettati per loro.
  3. Possono imporre la garbage collection per tutta la memoria e l'inizializzazione forzata, ottenendo così la sicurezza del puntatore.
  4. Sono stati specificati da un singolo attore che ha anche fornito la singola implementazione definitiva.
  5. Possono scegliere la sicurezza piuttosto che le prestazioni.

Ci sono buoni punti per le scelte però:

  1. La programmazione dei sistemi è un gioco completamente diverso, e invece l'ottimizzazione senza compromessi per la programmazione delle applicazioni è ragionevole.
  2. Certo, c'è sempre meno hardware esotico, ma i piccoli sistemi embedded sono qui per rimanere.
  3. GC non è adatto a risorse non fungibili e scambia molto più spazio per buone prestazioni. E la maggior parte (ma non quasi) delle inizializzazioni forzate può essere ottimizzata.
  4. Ci sono vantaggi per una maggiore concorrenza, ma i comitati significano compromesso.
  5. Tutti questi controlli dei limiti si sommano, anche se la maggior parte può essere ottimizzata via. I controlli dei puntatori null possono essere effettuati principalmente bloccando l'accesso per zero overhead grazie allo spazio di indirizzi virtuale, sebbene l'ottimizzazione sia ancora inibita.

Laddove vengono forniti i tratteggi di fuga, questi invitano nuovamente a comportarsi in modo indefinito. Ma almeno sono generalmente utilizzati solo in pochi tratti molto brevi, che sono quindi più facili da verificare manualmente.


3
Infatti. Programma in C # per il mio lavoro. Ogni tanto cerco uno dei martelli non sicuri ( unsafeparola chiave o attributi in System.Runtime.InteropServices). Mantenendo questa roba ai pochi programmatori che sanno come eseguire il debug di roba non gestita e di nuovo il meno possibile, tratteniamo i problemi. Sono passati più di 10 anni dall'ultimo martello non sicuro legato alle prestazioni, ma a volte devi farlo perché non c'è letteralmente altra soluzione.
Giosuè il

19
Lavoro spesso su una piattaforma da dispositivi analogici in cui sizeof (char) == sizeof (short) == sizeof (int) == sizeof (float) == 1. Fa anche saturare l'addizione (quindi INT_MAX + 1 == INT_MAX) e la cosa bella di C è che posso avere un compilatore conforme che genera un codice ragionevole. Se il linguaggio richiesto dicesse che i due si completano con il wrap around, ogni aggiunta finirebbe con un test e un ramo, qualcosa di non starter in una parte focalizzata sul DSP. Questa è una parte di produzione attuale.
Dan Mills,

5
@BenVoigt Alcuni di noi vivono in un mondo in cui un piccolo computer è forse 4k di spazio di codice, uno stack di chiamata / ritorno a 8 livelli fisso, 64 byte di RAM, un orologio da 1 MHz e costa <$ 0,20 in quantità 1.000. Un moderno telefono cellulare è un piccolo PC con una memoria praticamente illimitata a tutti gli effetti e può essere praticamente trattato come un PC. Non tutto il mondo è multicore e manca di vincoli in tempo reale.
Dan Mills,

2
@DanMills: qui non si parla di telefoni cellulari moderni con processori Arm Cortex A, si parla di "feature phone" intorno al 2002. Sì 192kB di SRAM sono molto più di 64 byte (che non sono "piccoli" ma "piccoli"), ma 192kB inoltre non è stato accuratamente definito desktop o server "moderno" per 30 anni. Anche in questi giorni 20 centesimi ti daranno un MSP430 con molto più di 64 byte di SRAM.
Ben Voigt,

2
@BenVoigt 192kB potrebbe non essere un desktop negli ultimi 30 anni, ma posso assicurarti che è del tutto sufficiente servire pagine web, che direi che rende tale server un server dalla definizione stessa della parola. Il fatto è che è una quantità di RAM del tutto ragionevole (generosa, uniforme) per MOLTE applicazioni integrate che spesso includono server Web di configurazione. Certo, probabilmente non sto eseguendo Amazon su di esso, ma potrei solo essere in esecuzione un frigorifero completo di crapware IOT su un tale nucleo (con tempo e spazio libero). Nessuno ha bisogno di lingue interpretate o JIT per questo!
Dan Mills,

8

Java e C # sono caratterizzati da un fornitore dominante, almeno all'inizio del loro sviluppo. (Rispettivamente Sun e Microsoft). C e C ++ sono diversi; hanno avuto molteplici implementazioni concorrenti sin dall'inizio. C funzionava anche su piattaforme hardware esotiche. Di conseguenza, c'è stata una variazione tra le implementazioni. I comitati ISO che hanno standardizzato C e C ++ potrebbero concordare un grande denominatore comune, ma ai margini in cui le implementazioni differiscono gli standard lasciano spazio all'implementazione.

Questo anche perché la scelta di un comportamento potrebbe essere costosa per le architetture hardware che sono orientate verso un'altra scelta: l'endianness è la scelta ovvia.


Cosa significa letteralmente un "grande denominatore comune" ? Stai parlando di sottoinsiemi o superset? Intendi davvero abbastanza fattori in comune? È come il minimo comune multiplo o il massimo comune fattore? Questo è molto confuso per noi robot che non parlano il gergo di strada, solo matematica. :)
tchrist

@tchrist: il comportamento comune è un sottoinsieme, ma questo sottoinsieme è piuttosto astratto. In molte aree non specificate dallo standard comune, le implementazioni reali devono fare una scelta. Ora alcune di queste scelte sono abbastanza chiare e quindi definite dall'implementazione, ma altre sono più vaghe. Il layout della memoria in fase di esecuzione è un esempio: ci deve essere una scelta, ma non è chiaro come lo documenteresti.
MSalters il

2
La C originale è stata realizzata da un ragazzo. Aveva già un sacco di UB, di progettazione. Le cose certamente peggiorarono quando C divenne popolare, ma UB era lì fin dall'inizio. Pascal e Smalltalk avevano molto meno UB e sono stati sviluppati praticamente nello stesso momento. Il vantaggio principale di C era che era estremamente facile portarlo - tutti i problemi di portabilità erano delegati al programmatore dell'applicazione: P Ho persino portato un semplice compilatore C sulla mia CPU (virtuale); fare qualcosa come LISP o Smalltalk sarebbe stato uno sforzo molto maggiore (anche se avevo un prototipo limitato per un runtime .NET :).
Luaan,

@Luaan: sarebbe Kernighan o Ritchie? E no, non aveva un comportamento indefinito. Lo so, ho avuto la documentazione originale del compilatore stencil AT&T sulla mia scrivania. L'implementazione ha fatto quello che ha fatto. Non c'era distinzione tra comportamento non specificato e non definito.
MSalters il

4
@MSalters Ritchie è stato il primo ragazzo. Kernighan si unì (non molto) più tardi. Bene, non aveva "Undefined Behaviour", perché quel termine non esisteva ancora. Ma aveva lo stesso comportamento che oggi verrebbe definito indefinito. Dato che C non aveva una specifica, anche "non specificato" è un tratto :) Era solo qualcosa a cui il compilatore non importava, ei dettagli dipendevano dai programmatori dell'applicazione. Non è stato progettato per produrre applicazioni portatili , solo il compilatore doveva essere facilmente trasportabile.
Luaan,

6

Il vero motivo si riduce a una differenza fondamentale nell'intento tra C e C ++ da un lato, e Java e C # (solo per un paio di esempi) dall'altro. Per ragioni storiche, gran parte della discussione qui parla di C piuttosto che di C ++, ma (come probabilmente già saprai) C ++ è un discendente abbastanza diretto di C, quindi ciò che dice di C si applica ugualmente a C ++.

Sebbene siano in gran parte dimenticati (e la loro esistenza a volte addirittura negata), le primissime versioni di UNIX sono state scritte in linguaggio assembly. Gran parte (se non esclusivamente) lo scopo originale di C era il port UNIX dal linguaggio assembly a un linguaggio di livello superiore. Parte dell'intento era scrivere il più possibile del sistema operativo in un linguaggio di livello superiore - o guardarlo dall'altra direzione, per ridurre al minimo la quantità che doveva essere scritta in linguaggio assembly.

A tale scopo, C doveva fornire quasi lo stesso livello di accesso all'hardware del linguaggio assembly. Il PDP-11 (per un esempio) ha mappato i registri I / O su indirizzi specifici. Ad esempio, avresti letto una posizione di memoria per verificare se era stato premuto un tasto sulla console di sistema. È stato impostato un bit in quella posizione quando c'erano dati in attesa di essere letti. Quindi leggere un byte da un'altra posizione specificata per recuperare il codice ASCII del tasto che era stato premuto.

Allo stesso modo, se si desidera stampare alcuni dati, è necessario controllare un'altra posizione specificata e quando il dispositivo di output era pronto, scrivere i dati ancora un'altra posizione specificata.

Per supportare la scrittura di driver per tali dispositivi, C ha permesso di specificare una posizione arbitraria utilizzando un tipo intero, convertirlo in un puntatore e leggere o scrivere quella posizione in memoria.

Naturalmente, questo ha un problema piuttosto grave: non tutte le macchine sulla terra hanno la sua memoria identica a un PDP-11 dei primi anni '70. Quindi, quando prendi quel numero intero, lo converti in un puntatore e poi leggi o scrivi tramite quel puntatore, nessuno può fornire alcuna ragionevole garanzia su ciò che otterrai. Solo per un esempio ovvio, la lettura e la scrittura possono essere associate a registri separati nell'hardware, quindi tu (contrariamente alla memoria normale) se scrivi qualcosa, quindi prova a rileggerlo, ciò che leggi potrebbe non corrispondere a ciò che hai scritto.

Vedo alcune possibilità che lascia:

  1. Definisci un'interfaccia per tutto l'hardware possibile: specifica gli indirizzi assoluti di tutte le posizioni che potresti voler leggere o scrivere per interagire con l'hardware in qualsiasi modo.
  2. Proibire quel livello di accesso e decretare che chiunque voglia fare tali cose deve usare il linguaggio assembly.
  3. Consenti alle persone di farlo, ma lascia loro la possibilità di leggere (ad esempio) i manuali per l'hardware a cui sono destinati e di scrivere il codice per adattarlo all'hardware che stanno utilizzando.

Di questi, 1 sembra sufficientemente assurdo da non meritare ulteriori discussioni. 2 sta praticamente eliminando l'intento di base della lingua. Ciò lascia la terza opzione come essenzialmente l'unica che potrebbero ragionevolmente considerare.

Un altro punto che emerge abbastanza frequentemente sono le dimensioni dei tipi interi. C prende la "posizione" che intdovrebbe essere la dimensione naturale suggerita dall'architettura. Quindi, se sto programmando un VAX a 32 bit, intprobabilmente dovrebbe essere 32 bit, ma se sto programmando un Univac a 36 bit, intprobabilmente dovrebbe essere 36 bit (e così via). Probabilmente non è ragionevole (e potrebbe anche non essere possibile) scrivere un sistema operativo per un computer a 36 bit utilizzando solo tipi di dimensioni garantite di multipli di 8 bit. Forse sono solo superficiale, ma mi sembra che se stavo scrivendo un sistema operativo per una macchina a 36 bit, probabilmente avrei voluto usare un linguaggio che supportasse un tipo a 36 bit.

Da un punto di vista linguistico, questo porta a comportamenti ancora più indefiniti. Se prendo il valore più grande che si adatta a 32 bit, cosa accadrà quando aggiungo 1? Sull'hardware tipico a 32 bit, verrà eseguito il roll over (o eventualmente gettato una sorta di errore hardware). D'altra parte, se è in esecuzione su hardware a 36 bit, semplicemente ... ne aggiungerà uno. Se la lingua supporterà la scrittura di sistemi operativi, non puoi garantire nessuno dei due comportamenti: devi solo consentire che le dimensioni dei tipi e il comportamento dell'overflow possano variare l'uno dall'altro.

Java e C # possono ignorare tutto ciò. Non intendono supportare la scrittura di sistemi operativi. Con loro, hai un paio di scelte. Uno è quello di fare in modo che l'hardware supporti ciò di cui hanno bisogno, poiché richiedono tipi da 8, 16, 32 e 64 bit, basta creare hardware che supporti quelle dimensioni. L'altra ovvia possibilità è che la lingua venga eseguita solo su altri software che forniscono l'ambiente che desiderano, indipendentemente da ciò che l'hardware sottostante potrebbe desiderare.

Nella maggior parte dei casi, questa non è davvero una scelta o / o. Piuttosto, molte implementazioni fanno un po 'di entrambi. Normalmente si esegue Java su una JVM in esecuzione su un sistema operativo. Più spesso, il sistema operativo è scritto in C e la JVM in C ++. Se la JVM è in esecuzione su una CPU ARM, è abbastanza probabile che la CPU includa le estensioni Jazelle di ARM, per adattare l'hardware più vicino alle esigenze di Java, quindi è necessario fare meno nel software e il codice Java funziona più velocemente (o meno lentamente, comunque).

Sommario

C e C ++ hanno un comportamento indefinito, perché nessuno ha definito un'alternativa accettabile che permetta loro di fare ciò che intendono fare. C # e Java adottano un approccio diverso, ma tale approccio si adatta male (se non del tutto) agli obiettivi di C e C ++. In particolare, nessuno dei due sembra fornire un modo ragionevole per scrivere software di sistema (come un sistema operativo) sull'hardware scelto arbitrariamente. Entrambi in genere dipendono dalle funzionalità fornite dal software di sistema esistente (solitamente scritto in C o C ++) per svolgere il proprio lavoro.


4

Gli autori dello standard C si aspettavano che i loro lettori riconoscessero qualcosa che pensavano fosse ovvio e alludevano al loro razionale pubblicato, ma non hanno detto apertamente: il Comitato non avrebbe dovuto ordinare agli scrittori di compilatori di soddisfare le esigenze dei loro clienti, poiché i clienti dovrebbero sapere meglio del Comitato quali sono le loro esigenze. Se è ovvio che ci si aspetta che i compilatori per determinati tipi di piattaforme elaborino un costrutto in un certo modo, a nessuno dovrebbe importare se lo Standard afferma che il costrutto invoca un comportamento indefinito. L'incapacità dello Standard di imporre che i compilatori conformi elaborino utilmente un pezzo di codice non implica in alcun modo che i programmatori dovrebbero essere disposti ad acquistare compilatori che non lo fanno.

Questo approccio alla progettazione del linguaggio funziona molto bene in un mondo in cui gli autori di compilatori devono vendere i loro prodotti a clienti paganti. Cade completamente a pezzi in un mondo in cui gli autori di compilatori sono isolati dagli effetti del mercato. È dubbio che esisteranno mai le condizioni di mercato adeguate per orientare una lingua nel modo in cui avevano guidato quella che è diventata popolare negli anni '90, e ancor più dubbio che qualsiasi progettista di lingua sana vorrebbe fare affidamento su tali condizioni di mercato.


Sento che hai descritto qualcosa di importante qui, ma mi sfugge. Potresti chiarire la tua risposta? Soprattutto il secondo paragrafo: dice che le condizioni ora e le condizioni precedenti sono diverse, ma non capisco; cosa è cambiato esattamente? Inoltre, il "modo" ora è diverso rispetto a prima; forse spiegare anche questo?
Anatolyg

4
Sembra che la tua campagna sostituisca tutti i comportamenti indefiniti con comportamenti non specificati o che qualcosa di più vincolato stia ancora andando forte.
Deduplicatore

1
@anatolyg: se non lo hai già fatto, leggi il documento C Rationale pubblicato (digita C99 Rationale su Google). Le linee 23-29 parlano del "mercato" e le pagine 5-8 linee 5-8 parlano di ciò che si intende per portabilità. Come pensi che reagirebbe un capo di una società di compilazione commerciale se uno scrittore di compilatori dicesse ai programmatori che si sono lamentati che l'ottimizzatore ha rotto il codice che ogni altro compilatore ha gestito utilmente che il loro codice era "rotto" perché compiva azioni non definite dalla norma, e ha rifiutato di sostenerlo perché ciò avrebbe promosso il proseguimento ...
supercat

1
... uso di tali costrutti? Un simile punto di vista è immediatamente evidente nelle schede di supporto di clang e gcc, ed è servito a impedire lo sviluppo di elementi intrinseci che potrebbero facilitare l'ottimizzazione in modo molto più semplice e sicuro di quanto il linguaggio rotto gcc e clang vogliano supportare.
supercat

1
@supercat: stai sprecando il fiato lamentandoti con i venditori di compilatori. Perché non indirizzare le tue preoccupazioni ai comitati linguistici? Se sono d'accordo con te, verrà emesso un errata che puoi usare per battere le squadre del compilatore in testa. E quel processo è molto più veloce dello sviluppo di una nuova versione del linguaggio. Ma se non sono d'accordo, avrai almeno delle vere ragioni, mentre gli autori del compilatore ripeteranno (ancora e ancora) "Non abbiamo designato quel codice rotto, quella decisione è stata presa dal comitato linguistico e noi seguire la loro decisione ".
Ben Voigt,

3

C ++ e c hanno entrambi standard descrittivi (le versioni ISO, comunque).

Che esistono solo per spiegare come funzionano le lingue e per fornire un unico riferimento su quale sia la lingua. In genere, i fornitori di compilatori e gli scrittori di biblioteche aprono la strada e alcuni suggerimenti vengono inclusi nello standard ISO principale.

Java e C # (o Visual C #, che presumo tu intenda) hanno standard prescrittivi . Ti dicono cosa c'è nella lingua in modo definitivo in anticipo, come funziona e cosa è considerato comportamento consentito.

Ancora più importante, Java ha effettivamente una "implementazione di riferimento" in Open-JDK. (Penso che Roslyn conti come l'implementazione di riferimento di Visual C #, ma non sono riuscito a trovare una fonte per quello.)

Nel caso di Java, se c'è qualche ambiguità nello standard e Open-JDK lo fa in un certo modo. Il modo in cui Open-JDK lo fa è lo standard.


La situazione è peggiore di quella: non credo che il Comitato abbia mai raggiunto un consenso sul fatto che debba essere descrittivo o prescrittivo.
supercat

1

Il comportamento indefinito consente al compilatore di generare codice molto efficiente su una varietà di architetti. La risposta di Erik menziona l'ottimizzazione, ma va oltre.

Ad esempio, gli overflow firmati sono comportamenti indefiniti in C. In pratica, si prevedeva che il compilatore generasse un semplice codice operativo aggiuntivo addizionale per l'esecuzione della CPU, e il comportamento sarebbe qualunque cosa facesse quella particolare CPU.

Ciò ha permesso a C di funzionare molto bene e produrre codice molto compatto sulla maggior parte delle architetture. Se lo standard avesse specificato che gli interi con segno dovevano traboccare in un certo modo, le CPU che si comportavano diversamente avrebbero avuto bisogno di molto più codice per generare una semplice aggiunta firmata.

Questa è la ragione di gran parte del comportamento indefinito in C, e perché cose come la dimensione di intvariano tra i sistemi. Intdipende dall'architettura e generalmente è selezionato per essere il tipo di dati più veloce ed efficiente che è più grande di un char.

Quando C era nuovo queste considerazioni erano importanti. I computer erano meno potenti, spesso con velocità e memoria di elaborazione limitate. La C è stata utilizzata laddove le prestazioni erano davvero importanti e gli sviluppatori dovevano comprendere come i computer funzionassero abbastanza bene da sapere quali comportamenti non definiti sarebbero stati effettivamente sui loro sistemi particolari.

Linguaggi successivi come Java e C # hanno preferito eliminare il comportamento indefinito rispetto alle prestazioni non elaborate.


-5

In un certo senso, anche Java ce l'ha. Supponiamo che tu abbia fornito un comparatore errato ad Arrays.sort. Può lanciare un'eccezione e lo rileva. Altrimenti ordinerà un array in qualche modo che non è garantito per essere particolare.

Allo stesso modo se si modificano le variabili da più thread i risultati sono anche imprevedibili.

Il C ++ è appena andato oltre per creare un numero indefinito di situazioni (o piuttosto Java ha deciso di definire più operazioni) e di avere un nome per esso.


4
Non è un comportamento indefinito del tipo di cui stiamo parlando qui. I "comparatori non corretti" sono di due tipi: quelli che definiscono un ordine totale e quelli che non lo fanno. Se si fornisce un comparatore che definisce in modo coerente l'ordinamento relativo degli articoli, il comportamento è ben definito, non è proprio il comportamento desiderato dal programmatore. Se si fornisce un comparatore non coerente con il relativo ordinamento, il comportamento è ancora ben definito: la funzione di ordinamento genererà un'eccezione (che probabilmente non è il comportamento desiderato dal programmatore).
Segna il

2
Per quanto riguarda la modifica delle variabili, le condizioni di gara generalmente non sono considerate comportamenti indefiniti. Non conosco i dettagli di come Java gestisce le assegnazioni ai dati condivisi, ma conoscendo la filosofia generale del linguaggio, sono abbastanza sicuro che debba essere atomico. Assegnare contemporaneamente 53 e 71 a asarebbe un comportamento indefinito se si potesse ottenere 51 o 73 da esso, ma se si può ottenere solo 53 o 71, è ben definito.
Segna il

@Mark Con blocchi di dati più grandi della dimensione della parola nativa del sistema (ad esempio, una variabile a 32 bit su un sistema della dimensione della parola a 16 bit), è possibile avere un'architettura che richiede l'archiviazione di ciascuna porzione a 16 bit separatamente. (SIMD è un'altra potenziale situazione del genere.) In tal caso, anche una semplice assegnazione a livello di codice sorgente non è necessariamente atomica a meno che il compilatore non prenda particolare attenzione per assicurarsi che venga eseguito atomicamente.
un CVn il
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.