In che modo le variabili in C ++ memorizzano il loro tipo?


42

Se definisco una variabile di un certo tipo (che, per quanto ne so, alloca solo i dati per il contenuto della variabile), come tiene traccia di quale tipo di variabile è?


8
A chi / a cosa ti riferisci " it " in " come tiene traccia "? Il compilatore o la CPU o qualcos'altro / come la lingua o il programma?
Erik Eidt,


8
@ErikEidt IMO l'OP ovviamente significa "la variabile stessa" per "esso". Ovviamente la risposta di due parole alla domanda è "no".
alephzero,

2
ottima domanda! particolarmente rilevante oggi dati tutti i linguaggi fantasiosi che memorizzano il loro tipo.
Trevor Boyd Smith,

@alephzero Era ovviamente una domanda principale.
Luaan,

Risposte:


105

Le variabili (o più in generale: "oggetti" nel senso di C) non memorizzano il loro tipo in fase di esecuzione. Per quanto riguarda il codice macchina, esiste solo memoria non tipizzata. Invece, le operazioni su questi dati interpretano i dati come un tipo specifico (ad es. Come float o come puntatore). I tipi vengono utilizzati solo dal compilatore.

Ad esempio, potremmo avere una struttura o una classe struct Foo { int x; float y; };e una variabile Foo f {}. Come si può auto result = f.y;compilare un accesso al campo ? Il compilatore sa che fè un oggetto di tipo Fooe conosce il layout di Foo-objects. A seconda dei dettagli specifici della piattaforma, questo potrebbe essere compilato come "Porta il puntatore all'inizio di f, aggiungi 4 byte, quindi carica 4 byte e interpreta questi dati come float". In molti set di istruzioni del codice macchina (incl. X86-64 ) ci sono diverse istruzioni del processore per il caricamento di float o ints.

Un esempio in cui il sistema di tipo C ++ non può tenere traccia del tipo per noi è un'unione simile union Bar { int as_int; float as_float; }. Un'unione contiene fino a un oggetto di vari tipi. Se memorizziamo un oggetto in un'unione, questo è il tipo attivo dell'unione. Dobbiamo solo tentare di riportare quel tipo fuori dal sindacato, qualsiasi altra cosa sarebbe un comportamento indefinito. O "sappiamo" durante la programmazione del tipo attivo, oppure possiamo creare un'unione taggata in cui memorizziamo un tag di tipo (di solito un enum) separatamente. Questa è una tecnica comune in C, ma poiché dobbiamo mantenere l'unione e il tag type sincronizzati, questo è piuttosto soggetto a errori. Un void*puntatore è simile a un'unione ma può contenere solo oggetti puntatore, tranne i puntatori a funzione.
Il C ++ offre due meccanismi migliori per gestire oggetti di tipo sconosciuto: possiamo usare tecniche orientate agli oggetti per eseguire la cancellazione del tipo (interagire con l'oggetto solo attraverso metodi virtuali in modo che non abbiamo bisogno di conoscere il tipo reale), oppure possiamo uso std::variant, una specie di unione sicura.

C'è un caso in cui C ++ memorizza il tipo di un oggetto: se la classe dell'oggetto ha dei metodi virtuali (un "tipo polimorfico", aka. Interfaccia). La destinazione di una chiamata al metodo virtuale non è nota al momento della compilazione e viene risolta in fase di esecuzione in base al tipo dinamico dell'oggetto ("invio dinamico"). La maggior parte dei compilatori lo implementano memorizzando una tabella di funzioni virtuali ("vtable") all'inizio dell'oggetto. La vtable può anche essere usata per ottenere il tipo di oggetto in fase di esecuzione. Possiamo quindi fare una distinzione tra il tipo statico noto di un'espressione in fase di compilazione e il tipo dinamico di un oggetto in fase di esecuzione.

Il C ++ ci consente di ispezionare il tipo dinamico di un oggetto con l' typeid()operatore che ci fornisce un std::type_infooggetto. Il compilatore conosce il tipo di oggetto al momento della compilazione oppure il compilatore ha archiviato le informazioni sul tipo necessarie all'interno dell'oggetto e può recuperarlo in fase di esecuzione.


3
Molto completo.
Deduplicatore,

9
Si noti che per accedere al tipo di un oggetto polimorfico il compilatore deve ancora sapere che l'oggetto appartiene a una particolare famiglia di ereditarietà (cioè avere un riferimento / puntatore digitato sull'oggetto, non void*).
Ruslan,

5
+0 perché la prima frase non è vera, gli ultimi due paragrafi la correggono.
Marcin,

3
Generalmente ciò che viene memorizzato all'inizio di un oggetto polimorfico è un puntatore alla tabella dei metodi virtuali, non alla tabella stessa.
Peter Green,

3
@ v.oddou Nel mio paragrafo ho ignorato alcuni dettagli. typeid(e)introspetta il tipo statico dell'espressione e. Se il tipo statico è un tipo polimorfico, l'espressione verrà valutata e verrà recuperato il tipo dinamico dell'oggetto. Non è possibile puntare typeid alla memoria di tipo sconosciuto e ottenere informazioni utili. Ad esempio, il tipo di unione descrive l'unione, non l'oggetto nell'unione. Il typeid di a void*è solo un puntatore vuoto. E non è possibile dedurre a void*per ottenere il suo contenuto. In C ++ non c'è boxe se non programmato esplicitamente in quel modo.
Amon,

51

L'altra risposta spiega bene l'aspetto tecnico, ma vorrei aggiungere alcuni "come pensare al codice macchina" in generale.

Il codice macchina dopo la compilazione è piuttosto stupido, e presuppone davvero che tutto funzioni come previsto. Supponiamo che tu abbia una funzione semplice come

bool isEven(int i) { return i % 2 == 0; }

Prende un int e sputa un bool.

Dopo averlo compilato, puoi pensarlo come qualcosa di simile a questo spremiagrumi automatico:

spremiagrumi automatico

Prende le arance e restituisce il succo. Riconosce il tipo di oggetti in cui entra? No, dovrebbero essere solo arance. Cosa succede se si ottiene una mela anziché un'arancia? Forse si romperà. Non importa, poiché un proprietario responsabile non proverà ad usarlo in questo modo.

La funzione sopra è simile: è progettata per prendere ints e può rompersi o fare qualcosa di irrilevante quando viene nutrita con qualcos'altro. Non importa (di solito), perché il compilatore (in genere) verifica che non accada mai - e in effetti non accade mai in un codice ben formato. Se il compilatore rileva la possibilità che una funzione ottenga un valore digitato errato, rifiuta di compilare il codice e restituisce invece errori di tipo.

L'avvertenza è che ci sono alcuni casi di codice mal formato che il compilatore passerà. Esempi sono:

  • errato tipo-casting: cast espliciti si presume siano corrette, ed è il programmatore per assicurarsi che non è colata void*a orange*quando c'è una mela su l'altra estremità del puntatore,
  • problemi di gestione della memoria come puntatori null, puntatori pendenti o use-after-scope; il compilatore non è in grado di trovarne la maggior parte,
  • Sono sicuro che c'è qualcos'altro che mi manca.

Come detto, il codice compilato è proprio come la macchina spremiagrumi - non sa cosa elabora, esegue solo le istruzioni. E se le istruzioni sono sbagliate, si rompe. Ecco perché i problemi di cui sopra in C ++ comportano arresti non controllati.


4
Il compilatore tenta di verificare che alla funzione venga passato un oggetto del tipo corretto, ma sia C che C ++ sono troppo complessi per essere comprovati dal compilatore. Quindi, il tuo confronto di mele e arance con lo spremiagrumi è piuttosto istruttivo.
Calchas,

@Calchas Grazie per il tuo commento! Questa frase è stata davvero una semplificazione eccessiva. Ho approfondito un po 'i possibili problemi, in realtà sono piuttosto legati alla domanda.
Frax,

5
wow grande metafora del codice macchina! la tua metafora è migliorata di 10 volte anche dall'immagine!
Trevor Boyd Smith,

2
"Sono sicuro che c'è qualcos'altro che mi manca." - Ovviamente! Di C void*esercita una coercizione a foo*, le solite promozioni aritmetiche, uniontipo giochi di parole, NULLcontro nullptr, anche solo avendo una brutta puntatore è UB, ecc, ma non credo che elenca tutte quelle cose sarebbe materialmente migliorare la vostra risposta, quindi è probabilmente meglio lasciare così com'è.
Kevin,

@Kevin Non credo sia necessario aggiungere C qui, poiché la domanda è contrassegnata solo come C ++. E in C ++ void*non si converte implicitamente in foo*, e il uniontipo di puntatura non è supportato (ha UB).
Ruslan,

3

Una variabile ha un numero di proprietà fondamentali in un linguaggio come C:

  1. Un nome
  2. Un tipo
  3. Un ambito
  4. Una vita intera
  5. Un posto
  6. Un valore

Nel codice sorgente , la posizione, (5), è concettuale e questa posizione è indicata dal suo nome, (1). Quindi, una dichiarazione di variabile viene utilizzata per creare la posizione e lo spazio per il valore, (6), e in altre linee di origine, ci riferiamo a quella posizione e al valore che detiene nominando la variabile in qualche espressione.

Semplificando solo un po ', una volta che il programma è stato tradotto nel codice macchina dal compilatore, l'ubicazione, (5), è un po' di memoria o posizione del registro CPU e tutte le espressioni di codice sorgente che fanno riferimento alla variabile vengono tradotte in sequenze di codice macchina che fanno riferimento a quella memoria o posizione del registro CPU.

Pertanto, quando la traduzione è completata e il programma è in esecuzione sul processore, i nomi delle variabili vengono effettivamente dimenticati nel codice macchina e le istruzioni generate dal compilatore si riferiscono solo alle posizioni assegnate delle variabili (piuttosto che alla loro nomi). Se si esegue il debug e si richiede il debug, la posizione della variabile associata al nome viene aggiunta ai metadati per il programma, sebbene il processore veda comunque le istruzioni del codice macchina utilizzando le posizioni (non quei metadati). (Questa è una semplificazione eccessiva in quanto alcuni nomi sono nei metadati del programma ai fini del collegamento, caricamento e ricerca dinamica - il processore esegue solo le istruzioni del codice macchina che gli viene detto per il programma, e in questo codice macchina i nomi hanno è stato convertito in località.)

Lo stesso vale anche per il tipo, l'ambito e la durata. Le istruzioni del codice macchina generate dal compilatore conoscono la versione della macchina della posizione, che memorizza il valore. Le altre proprietà, come il tipo, vengono compilate nel codice sorgente tradotto come istruzioni specifiche che accedono alla posizione della variabile. Ad esempio, se la variabile in questione è un byte a 8 bit con segno rispetto a un byte a 8 bit senza segno, quindi le espressioni nel codice sorgente che fanno riferimento alla variabile verranno tradotte in, per esempio, carichi di byte con segno rispetto a carichi di byte senza segno, se necessario per soddisfare le regole del linguaggio (C). Il tipo della variabile viene quindi codificato nella traduzione del codice sorgente in istruzioni macchina, che comandano alla CPU come interpretare la posizione della memoria o del registro della CPU ogni volta che utilizza la posizione della variabile.

L'essenza è che dobbiamo dire alla CPU cosa fare tramite le istruzioni (e più istruzioni) nel set di istruzioni del codice macchina del processore. Il processore ricorda molto poco di ciò che ha appena fatto o detto: esegue solo le istruzioni fornite ed è compito del programmatore del linguaggio di compilazione o assembly fornire un set completo di sequenze di istruzioni per manipolare correttamente le variabili.

Un processore supporta direttamente alcuni tipi di dati fondamentali, come byte / word / int / long sign / unsigned, float, double, ecc. Il processore generalmente non si lamenterà o obietterà se si tratta alternativamente la stessa posizione di memoria come firmata o non firmata, per esempio, anche se di solito sarebbe un errore logico nel programma. È compito della programmazione istruire il processore ad ogni interazione con una variabile.

Al di là di quei tipi primitivi fondamentali, dobbiamo codificare le cose nelle strutture di dati e usare algoritmi per manipolarle in termini di quelle primitive.

In C ++, gli oggetti coinvolti nella gerarchia di classi per il polimorfismo hanno un puntatore, di solito all'inizio dell'oggetto, che si riferisce a una struttura di dati specifica della classe, che aiuta con l'invio virtuale, il casting, ecc.

In sintesi, altrimenti il ​​processore non conosce o ricorda l'uso previsto delle posizioni di memoria - esegue le istruzioni del codice macchina del programma che gli dicono come manipolare la memoria nei registri della CPU e nella memoria principale. La programmazione, quindi, è il compito del software (e dei programmatori) di utilizzare l'archiviazione in modo significativo e di presentare al processore una serie coerente di istruzioni sul codice macchina che eseguono fedelmente il programma nel suo insieme.


1
Attento a "quando la traduzione è completata, il nome viene dimenticato" ... il collegamento avviene tramite nomi ("simbolo indefinito xy") e può avvenire in fase di esecuzione con il collegamento dinamico. Vedi blog.fesnel.com/blog/2009/08/19/… . Nessun simbolo di debug, anche eliminato: per il collegamento dinamico è necessario il nome della funzione (e, presumo, variabile globale). Quindi solo i nomi degli oggetti interni possono essere dimenticati. A proposito, un buon elenco di proprietà variabili.
Peter - Ripristina Monica il

@ PeterA.Schneider, hai perfettamente ragione, nel quadro generale delle cose, che anche linker e caricatori partecipino e utilizzino nomi di funzioni (globali) e variabili provenienti dal codice sorgente.
Erik Eidt,

Un'ulteriore complicazione è che alcuni compilatori interpretano regole che, secondo lo Standard, intendono consentire ai compilatori di supporre che alcune cose non siano alias, in quanto consentono loro di considerare le operazioni che coinvolgono tipi diversi come non seguite, anche nei casi che non comportano l'aliasing come scritto . Dato qualcosa del genere useT1(&unionArray[i].member1); useT2(&unionArray[j].member2); useT1(&unionArray[i].member1);, clang e gcc sono inclini a supporre che il puntatore unionArray[j].member2non possa accedervi unionArray[i].member1anche se entrambi sono derivati ​​dallo stesso unionArray[].
supercat,

Se il compilatore interpreta correttamente o meno le specifiche del linguaggio, il suo compito è generare sequenze di istruzioni del codice macchina che eseguono il programma. Ciò significa che (ottimizzazione del modulo e molti altri fattori) per ogni accesso variabile nel codice sorgente deve generare alcune istruzioni del codice macchina che indicano al processore quali dimensioni e interpretazione dei dati utilizzare per la posizione di archiviazione. Il processore non ricorda nulla della variabile, quindi ogni volta che dovrebbe accedere alla variabile, deve essere istruito esattamente su come farlo.
Erik Eidt,

2

se definisco una variabile di un certo tipo come tiene traccia del tipo di variabile che è.

Ci sono due fasi rilevanti qui:

  • Tempo di compilazione

Il compilatore C compila il codice C in linguaggio macchina. Il compilatore ha tutte le informazioni che può ottenere dal file sorgente (e dalle librerie e qualsiasi altra cosa di cui abbia bisogno per fare il suo lavoro). Il compilatore C tiene traccia di cosa significa cosa. Il compilatore C sa che se dichiari una variabile char, è char.

Lo fa usando una cosiddetta "tabella dei simboli" che elenca i nomi delle variabili, il loro tipo e altre informazioni. È una struttura di dati piuttosto complessa, ma puoi pensarla semplicemente tenendo traccia del significato dei nomi leggibili dall'uomo. Nell'output binario del compilatore non compaiono più nomi di variabili come questa (se ignoriamo le informazioni di debug facoltative che potrebbero essere richieste dal programmatore).

  • Runtime

L'output del compilatore, l'eseguibile compilato, è il linguaggio macchina, che viene caricato nella RAM dal sistema operativo ed eseguito direttamente dalla CPU. Nel linguaggio macchina, non esiste alcuna nozione di "tipo": ha solo comandi che operano in qualche posizione nella RAM. I comandi hanno davvero un tipo fisso operano con (vale a dire, ci può essere un comando di linguaggio macchina "aggiungere questi due interi a 16 bit memorizzati in posizioni RAM 0x100 e 0x521"), ma non ci sono informazioni ovunque nel sistema che il i byte in quelle posizioni in realtà rappresentano numeri interi. Non v'è alcuna protezione da errori di tipo affatto qui.


Se per caso ti riferisci a C # o Java con "linguaggi orientati al codice byte", i puntatori non li ometteranno affatto; al contrario: i puntatori sono molto più comuni in C # e Java (e di conseguenza, uno degli errori più comuni in Java è "NullPointerException"). Che siano chiamati "riferimenti" è solo una questione di terminologia.
Peter - Ripristina Monica il

@ PeterA.Schneider, certo, esiste NullPOINTERException, ma esiste una distinzione ben definita tra un riferimento e un puntatore nelle lingue che ho citato (come Java, ruby, probabilmente C #, persino Perl in una certa misura) - il riferimento va insieme con il loro sistema di tipi, la garbage collection, la gestione automatica della memoria ecc .; di solito non è nemmeno possibile dichiarare esplicitamente una posizione di memoria (come char *ptr = 0x123in C). Credo che il mio uso della parola "puntatore" dovrebbe essere abbastanza chiaro in questo contesto. In caso contrario, sentiti libero di avvisarmi e aggiungerò una frase alla risposta.
AnoE

i puntatori "vanno insieme al sistema di tipi" anche in C ++ ;-). (In realtà, i generici classici di Java sono meno tipizzati rispetto a quelli del C ++.) La garbage collection è una funzionalità che C ++ ha deciso di non imporre, ma è possibile che un'implementazione ne fornisca uno e non ha nulla a che fare con la parola che usiamo per i puntatori.
Peter - Ripristina Monica il

OK, @ PeterA.Schneider, non penso davvero che stiamo diventando di livello qui. Ho rimosso il paragrafo in cui ho citato i puntatori, comunque non ha fatto nulla per la risposta.
AnoE

1

Esistono un paio di importanti casi speciali in cui C ++ memorizza un tipo in fase di esecuzione.

La soluzione classica è un'unione discriminata: una struttura di dati che contiene uno dei diversi tipi di oggetto, oltre a un campo che indica il tipo che contiene attualmente. Una versione modello è nella libreria standard C ++ come std::variant. Normalmente, il tag sarebbe un enum, ma se non hai bisogno di tutti i bit di memoria per i tuoi dati, potrebbe essere un campo di bit.

L'altro caso comune è la digitazione dinamica. Quando hai classuna virtualfunzione, il programma memorizzerà un puntatore a quella funzione in una tabella di funzioni virtuali , che verrà inizializzata per ogni istanza del classmomento in cui viene costruita. Normalmente, ciò significherà una tabella di funzione virtuale per tutte le istanze di classe e ogni istanza con un puntatore alla tabella appropriata. (Ciò consente di risparmiare tempo e memoria perché la tabella sarà molto più grande di un singolo puntatore.) Quando si chiama quella virtualfunzione tramite un puntatore o un riferimento, il programma cercherà il puntatore della funzione nella tabella virtuale. (Se conosce il tipo esatto in fase di compilazione, può saltare questo passaggio.) Ciò consente al codice di chiamare l'implementazione di un tipo derivato anziché quella della classe base.

La cosa che rende questo rilevante qui è: ognuno ofstreamcontiene un puntatore alla ofstreamtabella virtuale, ciascuno ifstreamalla ifstreamtabella virtuale e così via. Per le gerarchie di classi, il puntatore della tabella virtuale può fungere da tag che indica al programma che tipo di oggetto di classe ha!

Sebbene lo standard linguistico non dica alle persone che progettano compilatori come devono implementare il runtime sotto il cofano, è così che ci si può aspettare dynamic_caste typeoflavorare.


"lo standard del linguaggio non dice ai programmatori" dovresti probabilmente sottolineare che i "programmatori" in questione sono quelli che scrivono gcc, clang, msvc, ecc., non persone che usano quelli per compilare il loro C ++.
Caleth,

@Caleth Buon suggerimento!
Davislor,
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.