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 è?
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 è?
Risposte:
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 Foo
e 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_info
oggetto. 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.
void*
).
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.
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:
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:
void*
a orange*
quando c'è una mela su l'altra estremità del puntatore,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.
void*
esercita una coercizione a foo*
, le solite promozioni aritmetiche, union
tipo giochi di parole, NULL
contro 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'è.
void*
non si converte implicitamente in foo*
, e il union
tipo di puntatura non è supportato (ha UB).
Una variabile ha un numero di proprietà fondamentali in un linguaggio come C:
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.
useT1(&unionArray[i].member1); useT2(&unionArray[j].member2); useT1(&unionArray[i].member1);
, clang e gcc sono inclini a supporre che il puntatore unionArray[j].member2
non possa accedervi unionArray[i].member1
anche se entrambi sono derivati dallo stesso unionArray[]
.
se definisco una variabile di un certo tipo come tiene traccia del tipo di variabile che è.
Ci sono due fasi rilevanti qui:
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).
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.
char *ptr = 0x123
in 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.
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 class
una virtual
funzione, il programma memorizzerà un puntatore a quella funzione in una tabella di funzioni virtuali , che verrà inizializzata per ogni istanza del class
momento 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 virtual
funzione 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 ofstream
contiene un puntatore alla ofstream
tabella virtuale, ciascuno ifstream
alla ifstream
tabella 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_cast
e typeof
lavorare.