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

41

Se definisco una variabile di un certo tipo (che, per quanto ne so, assegna solo i dati per il contenuto della variabile), come fa a tenere traccia del tipo di variabile che è?

    
posta Finn McClusky 21.10.2018 - 14:39
fonte

5 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, c'è 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 sono usati solo dal compilatore.

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

Un esempio in cui il sistema di tipo C ++ non può tenere traccia del tipo per noi è un'unione come 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 cercare di rimuovere quel tipo dall'unione, qualsiasi altra cosa sarebbe un comportamento indefinito. O "sappiamo" mentre programmiamo quale sia il tipo attivo, oppure possiamo creare un tagged union dove memorizziamo un tag type (di solito un enum) separatamente. Questa è una tecnica comune in C, ma poiché dobbiamo mantenere il sindacato e il tag type in sync, questo è abbastanza incline agli errori. Un puntatore void* è simile a un sindacato ma può contenere solo oggetti puntatore, ad eccezione dei puntatori di funzione.
C ++ offre due meccanismi migliori per gestire oggetti di tipi sconosciuti: possiamo usare tecniche orientate agli oggetti per eseguire type erasure (solo interagire con l'oggetto tramite metodi virtuali in modo che non abbiamo bisogno di conoscere il tipo effettivo), oppure possiamo usare std::variant , un tipo di unione sicura al tipo.

C'è un caso in cui C ++ memorizza il tipo di un oggetto: se la classe dell'oggetto ha dei metodi virtuali (un "tipo polimorfico", cioè un'interfaccia). L'obiettivo di una chiamata al metodo virtuale è sconosciuto al momento della compilazione e viene risolto in fase di esecuzione in base al tipo dinamico dell'oggetto ("dispacciamento dinamico"). Molti compilatori implementano questo memorizzando una tabella di funzioni virtuale ("vtable") all'inizio dell'oggetto. Il vtable può anche essere usato per ottenere il tipo dell'oggetto in fase di esecuzione. Possiamo quindi fare una distinzione tra il tipo statico noto di espressione in fase di compilazione e il tipo dinamico di un oggetto in fase di runtime.

C ++ ci consente di ispezionare il tipo dinamico di un oggetto con l'operatore typeid() che ci fornisce un oggetto std::type_info . O il compilatore conosce il tipo di oggetto in fase di compilazione, o il compilatore ha memorizzato le informazioni sul tipo necessarie all'interno dell'oggetto e può recuperarlo in fase di runtime.

    
risposta data 21.10.2018 - 15:19
fonte
51

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

Il codice della macchina dopo la compilazione è abbastanza stupido e in realtà presuppone che tutto funzioni come previsto. Supponi di avere una semplice funzione come

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

Ci vuole un int, e sputa un bool.

Dopo averlo compilato, puoi pensare a qualcosa come questo spremiagrumi automatico:

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

La funzione di cui sopra è simile: è progettata per essere eseguita, e può rompere o fare qualcosa di irrilevante quando viene alimentata qualcos'altro. Questo (di solito) non ha importanza, perché il compilatore (generalmente) 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 abbia un valore digitato errato, rifiuta di compilare il codice e restituisce invece gli errori di tipo.

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

  • type cast non corretto: si presuppone che i cast espliciti siano corretti, ed è sul programmatore per assicurarsi che non stia lanciando void* a orange* quando c'è una mela sull'altra estremità del puntatore,
  • problemi di gestione della memoria come puntatori nulli, puntatori penzolanti o uso dopo l'uso; 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 semplicemente le istruzioni. E se le istruzioni sono sbagliate, si rompe. Ecco perché i problemi sopra descritti in C ++ provocano arresti anomali incontrollati.

    
risposta data 21.10.2018 - 18:55
fonte
3

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

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

Nel tuo codice sorgente , la posizione, (5), è concettuale, e questa posizione è indicata con il 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 denominando la variabile in alcune espressioni.

Semplificando solo un po ', una volta che il programma è stato tradotto in codice macchina dal compilatore, il percorso, (5), è un po' di memoria o posizione del registro CPU e qualsiasi espressione del codice sorgente che fa riferimento alla variabile viene tradotta in sequenze di codice macchina che fai riferimento alla memoria o alla posizione del registro della CPU.

Quindi, quando la traduzione è completata e il programma è in esecuzione sul processore, i nomi delle variabili vengono effettivamente dimenticati all'interno del codice macchina e, le istruzioni generate dal compilatore si riferiscono solo alle posizioni assegnate alle variabili (piuttosto rispetto ai loro nomi). Se si esegue il debug e si richiede il debug, la posizione della variabile associata al nome viene aggiunta ai metadati del programma, sebbene il processore continui a visualizzare le istruzioni del codice macchina utilizzando le posizioni (non i metadati). (Si tratta di una semplificazione eccessiva poiché alcuni nomi sono nei metadati del programma ai fini del collegamento, del caricamento e della ricerca dinamica - il processore esegue semplicemente le istruzioni del codice macchina che gli viene detto per il programma e in questo codice macchina i nomi hanno stato convertito in posizioni.)

Lo stesso vale anche per tipo, ambito e durata. Le istruzioni del codice macchina generate dal compilatore conoscono la versione della macchina del luogo, che memorizza il valore. Le altre proprietà, come il tipo, sono 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, le espressioni nel codice sorgente che fanno riferimento alla variabile verranno convertite in, ad esempio, carichi di byte firmati e carichi di byte senza segno, come necessario per soddisfare le regole della (C) lingua. Il tipo di variabile è quindi codificato nella traduzione del codice sorgente in istruzioni macchina, che comandano alla CPU come interpretare la memoria o la posizione del registro CPU ogni volta che usa la posizione della variabile.

L'essenza è che dobbiamo dire alla CPU cosa fare tramite le istruzioni (e altre istruzioni) nel set di istruzioni del codice macchina del processore. Il processore ricorda molto poco di ciò che ha appena fatto o è stato detto: esegue solo le istruzioni fornite ed è compito del compilatore o del programmatore di linguaggio 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 signed / unsigned, float, double, ecc. In genere il processore non si lamenterà o non obietterà se si tratti alternativamente la stessa posizione di memoria come firmata o per esempio, senza segno, anche se questo sarebbe di solito un errore logico nel programma. È compito della programmazione istruire il processore ad ogni interazione con una variabile.

Al di là di questi tipi primitivi fondamentali, dobbiamo codificare le cose nelle strutture dati e utilizzare gli algoritmi per manipolarli in termini di quelle primitive.

In C ++, gli oggetti coinvolti nella gerarchia delle classi per il polimorfismo hanno un puntatore, solitamente all'inizio dell'oggetto, che fa riferimento a una struttura dati specifica della classe, che aiuta con invio, casting, ecc. virtuali

In sintesi, il processore in caso contrario non conosce o ricorda l'uso previsto delle posizioni di memoria - esegue le istruzioni del codice macchina del programma che indicano 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 un insieme coerente di istruzioni del codice macchina che eseguano fedelmente il programma nel suo complesso.

    
risposta data 21.10.2018 - 23:32
fonte
2

if I define a variable of a certain type how does it keep track of type of variable it is.

Qui ci sono due fasi rilevanti:

  • Tempo di compilazione

Il compilatore C compila il codice C sul linguaggio macchina. Il compilatore ha tutte le informazioni che può ottenere dal tuo file sorgente (e dalle librerie, e da qualsiasi altra cosa di cui ha bisogno per fare il suo lavoro). Il compilatore C tiene traccia di cosa significa cosa. Il compilatore C sa che se si dichiara una variabile come char , si tratta di 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 si può pensare a ciò semplicemente tenendo traccia di ciò che significano i nomi leggibili dall'uomo. Nell'output binario del compilatore, non appaiono più nomi di variabili come questo (se ignoriamo le informazioni di debug opzionali 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 vi è alcuna nozione di "tipo" - ha solo comandi che operano su qualche posizione nella RAM. I comandi hanno effettivamente un tipo fisso con cui operano (cioè, potrebbe esserci un comando di linguaggio macchina "aggiungi questi due numeri interi a 16 bit memorizzati nelle posizioni RAM 0x100 e 0x521"), ma non c'è informazioni ovunque nel sistema che i byte in quelle posizioni effettivamente rappresentino numeri interi. Qui non c'è alcuna protezione dagli errori di tipo .

    
risposta data 21.10.2018 - 22:17
fonte
1

Ci sono un paio di casi speciali importanti in cui C ++ memorizza un tipo in fase di runtime.

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

L'altro caso comune di questo è la digitazione dinamica. Quando class ha una funzione virtual , il programma memorizzerà un puntatore a quella funzione in una tabella di funzioni virtuali , che verrà inizializzata per ogni istanza di class quando viene costruita . Normalmente, ciò significa una tabella di funzioni virtuale per tutte le istanze di classe e ogni istanza che contiene un puntatore alla tabella appropriata. (Ciò risparmia tempo e memoria perché la tabella sarà molto più grande di un singolo puntatore.) Quando chiami la funzione virtual attraverso un puntatore o un riferimento, il programma cercherà il puntatore alla 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 invece delle classi base.

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

Sebbene lo standard del linguaggio non dica alle persone che progettano i compilatori come devono implementare il runtime sotto il cofano, ecco come puoi aspettarti che dynamic_cast e typeof funzionino.

    
risposta data 21.10.2018 - 23:52
fonte

Leggi altre domande sui tag