Perché i tipi hanno sempre una certa dimensione, indipendentemente dal loro valore?


149

Le implementazioni potrebbero differire tra le dimensioni effettive dei tipi, ma nella maggior parte dei casi, come unsigned int e float sono sempre 4 byte. Ma perché un tipo occupa sempre una certa quantità di memoria indipendentemente dal suo valore? Ad esempio, se ho creato il seguente numero intero con il valore di 255

int myInt = 255;

Quindi myIntoccuperei 4 byte con il mio compilatore. Tuttavia, il valore effettivo 255può essere rappresentato con solo 1 byte, quindi perché myIntnon occupare solo 1 byte di memoria? O il modo più generalizzato di chiedere: perché un tipo ha solo una dimensione associata ad esso quando lo spazio richiesto per rappresentare il valore potrebbe essere più piccolo di quella dimensione?


15
1) " Tuttavia, il valore effettivo, 256 può essere rappresentato con solo 1 byte " È sbagliato, il unsingedvalore più grande , che può essere rappresentato con 1 byte è 255. 2) Considerare l'overhead del calcolo delle dimensioni ottimali di archiviazione e della riduzione / espansione dell'area di archiviazione di una variabile al variare del valore.
Algirdas Preidžius,

99
Bene, quando arriva il momento di leggere il valore dalla memoria, come proponete che la macchina determinerà quanti byte leggere? Come farà la macchina a sapere dove smettere di leggere il valore? Ciò richiederà servizi aggiuntivi. E in generale il sovraccarico di memoria e prestazioni per queste strutture aggiuntive sarà molto più elevato rispetto al semplice utilizzo di 4 byte fissi per unsigned intvalore.
AnT

74
Mi piace davvero questa domanda. Anche se può sembrare semplice rispondere, penso che dare una spiegazione precisa richieda una buona comprensione di come funzionano effettivamente i computer e le architetture dei computer. Molte persone probabilmente lo daranno per scontato, senza avere una spiegazione esauriente per questo.
andreee,

37
Considera cosa accadrebbe se aggiungessi 1 al valore della variabile, rendendolo 256, quindi dovrebbe espandersi. Dove si espande? Muovi il resto della memoria per fare spazio? La variabile stessa si muove? In caso affermativo, dove si sposta e come si trovano i puntatori che è necessario aggiornare?
molbdnilo,

13
@someidiot no, ti sbagli. std::vector<X>ha sempre le stesse dimensioni, ovvero sizeof(std::vector<X>)è una costante di compilazione-tempo.
SergeyA

Risposte:


131

Si suppone che il compilatore produca assemblatore (e infine codice macchina) per alcune macchine, e generalmente C ++ cerca di essere simpatico con quella macchina.

Essere solidali con la macchina sottostante significa approssimativamente: semplificare la scrittura di codice C ++ che mapperà in modo efficiente sulle operazioni che la macchina può eseguire rapidamente. Pertanto, vogliamo fornire accesso ai tipi di dati e alle operazioni che sono veloci e "naturali" sulla nostra piattaforma hardware.

Concretamente, si consideri una specifica architettura della macchina. Prendiamo l'attuale famiglia Intel x86.

Il Manuale dello sviluppatore del software per architetture Intel® 64 e IA-32 vol 1 ( link ), sezione 3.4.1 dice:

I registri di uso generale a 32 bit EAX, EBX, ECX, EDX, ESI, EDI, EBP ed ESP sono forniti per contenere i seguenti elementi:

• Operandi per operazioni logiche e aritmetiche

• Operandi per i calcoli degli indirizzi

• Puntatori di memoria

Quindi, vogliamo che il compilatore utilizzi questi registri EAX, EBX ecc. Quando compila l'aritmetica intera C ++ semplice. Ciò significa che quando dichiaro un int, dovrebbe essere qualcosa di compatibile con questi registri, in modo da poterli utilizzare in modo efficiente.

I registri hanno sempre le stesse dimensioni (qui, 32 bit), quindi il mio int variabili saranno sempre 32 bit. Userò lo stesso layout (little-endian) in modo da non dover fare una conversione ogni volta che carico un valore di variabile in un registro o immagazzino un registro in una variabile.

Usando godbolt possiamo vedere esattamente cosa fa il compilatore per qualche codice banale:

int square(int num) {
    return num * num;
}

compila (con GCC 8.1 e -fomit-frame-pointer -O3per semplicità) per:

square(int):
  imul edi, edi
  mov eax, edi
  ret

questo significa:

  1. il int numparametro è stato passato nel registro EDI, il che significa che è esattamente la dimensione e il layout che Intel si aspetta da un registro nativo. La funzione non deve convertire nulla
  2. la moltiplicazione è una singola istruzione ( imul), che è molto veloce
  3. restituire il risultato è semplicemente una questione di copiarlo in un altro registro (il chiamante si aspetta che il risultato sia inserito in EAX)

Modifica: possiamo aggiungere un confronto rilevante per mostrare la differenza utilizzando un layout non nativo. Il caso più semplice è la memorizzazione di valori in qualcosa di diverso dalla larghezza nativa.

Usando di nuovo godbolt , possiamo confrontare una semplice moltiplicazione nativa

unsigned mult (unsigned x, unsigned y)
{
    return x*y;
}

mult(unsigned int, unsigned int):
  mov eax, edi
  imul eax, esi
  ret

con il codice equivalente per una larghezza non standard

struct pair {
    unsigned x : 31;
    unsigned y : 31;
};

unsigned mult (pair p)
{
    return p.x*p.y;
}

mult(pair):
  mov eax, edi
  shr rdi, 32
  and eax, 2147483647
  and edi, 2147483647
  imul eax, edi
  ret

Tutte le istruzioni aggiuntive riguardano la conversione del formato di input (due numeri interi senza segno a 31 bit) nel formato che il processore può gestire in modo nativo. Se volessimo salvare il risultato in un valore a 31 bit, ci sarebbero un'altra o due istruzioni per farlo.

Questa ulteriore complessità significa che ti preoccuperesti di questo solo quando il risparmio di spazio è molto importante. In questo caso stiamo salvando solo due bit rispetto all'utilizzo del nativo unsignedo del uint32_ttipo, che avrebbe generato un codice molto più semplice.


Una nota sulle dimensioni dinamiche:

L'esempio sopra è ancora valori a larghezza fissa anziché a larghezza variabile, ma la larghezza (e l'allineamento) non corrispondono più ai registri nativi.

La piattaforma x86 ha diverse dimensioni native, tra cui 8-bit e 16-bit oltre ai principali 32-bit (sto passando alla modalità 64-bit e varie altre cose per semplicità).

Questi tipi (char, int8_t, uint8_t, int16_t ecc) sono anche supportati direttamente dall'architettura, in parte per la compatibilità con le versioni precedenti di 8086/286/386 / ecc. set di istruzioni ecc.

È certamente il caso di scegliere la dimensione fissa naturale più piccola sufficiente, può essere una buona pratica: sono ancora veloci, vengono caricate e memorizzate singole istruzioni, si ottiene ancora un'aritmetica nativa a piena velocità e si può persino migliorare le prestazioni riduzione dei mancati cache.

Questo è molto diverso dalla codifica a lunghezza variabile: ho lavorato con alcuni di questi e sono orribili. Ogni carico diventa un ciclo anziché una singola istruzione. Ogni negozio è anche un ciclo. Ogni struttura ha una lunghezza variabile, quindi non è possibile utilizzare le matrici in modo naturale.


Un'ulteriore nota sull'efficienza

Nei commenti successivi, hai usato la parola "efficiente", per quanto ne so per quanto riguarda le dimensioni di archiviazione. A volte scegliamo di ridurre al minimo le dimensioni di archiviazione: può essere importante quando salviamo un numero molto elevato di valori nei file o li inviamo in rete. Il compromesso è che dobbiamo caricare quei valori nei registri per fare qualsiasi cosa con loro, ed eseguire la conversione non è gratuito.

Quando parliamo di efficienza, dobbiamo sapere cosa stiamo ottimizzando e quali sono i compromessi. L'uso di tipi di archiviazione non nativi è un modo per scambiare la velocità di elaborazione con lo spazio e talvolta ha senso. Utilizzando una memoria a lunghezza variabile (almeno per i tipi aritmetici), viene scambiata una maggiore velocità di elaborazione (e complessità del codice e tempo di sviluppo) per un ulteriore risparmio di spazio spesso minimo.

La penalità di velocità che paghi per questo significa che vale la pena solo quando devi minimizzare assolutamente la larghezza di banda o l'archiviazione a lungo termine, e in quei casi è solitamente più facile usare un formato semplice e naturale - e poi comprimerlo semplicemente con un sistema per scopi generici (come zip, gzip, bzip2, xy o altro).


tl; dr

Ogni piattaforma ha un'architettura, ma puoi creare un numero sostanzialmente illimitato di modi diversi di rappresentare i dati. Non è ragionevole per nessuna lingua fornire un numero illimitato di tipi di dati integrati. Pertanto, C ++ fornisce accesso implicito all'insieme naturale nativo della piattaforma di tipi di dati e consente di codificare personalmente qualsiasi altra rappresentazione (non nativa).


Sto cercando tutte le belle risposte mentre cerco di dare un senso a tutte loro. Quindi per quanto riguarda la tua risposta, non sarebbe una dimensione dinamica, dire meno di 32 bit per un numero intero, non consentire solo più variabili all'interno di un registro ? Se l'endianessa è la stessa, perché non dovrebbe essere ottimale?
Nichlas Uden,

7
@asd ma quanti registri userete nel codice per capire quante variabili sono attualmente memorizzate in un registro?
user253751

1
FWIW è comune impacchettare più valori nello spazio più piccolo disponibile dove si decide che il risparmio di spazio è più importante del costo rapido dell'imballaggio e del disimballaggio. In genere non è possibile operare su di essi in modo naturale nella loro forma compatta perché il processore non sa come eseguire l'aritmetica correttamente su qualsiasi cosa diversa dai suoi registri integrati. Cerca BCD per un'eccezione parziale con il supporto del processore
Inutile

3
Se io in realtà non servono tutti i 32 bit per un certo valore, ho ancora bisogno di un posto per memorizzare la lunghezza, in modo ora ho bisogno di più di 32 bit in alcuni casi.
Inutile

1
+1. Una nota sul "formato semplice e naturale e quindi comprimere" è in genere migliore: questo è generalmente vero , ma : per alcuni dati VLQ-ogni-valore-quindi-comprimere-il-tutto funziona in modo notevolmente migliore rispetto al solo comprimere-il -intero, e per alcune applicazioni, i tuoi dati non possono essere compressi insieme , perché sono disparati (come nei gitmetadati) o li stai effettivamente tenendo in memoria, occasionalmente devi accedere o modificare in modo casuale alcuni, ma non la maggior parte i valori (come nei motori di rendering HTML + CSS), e quindi può essere evitato solo usando qualcosa come VLQ sul posto.
mtraceur,

139

Perché i tipi rappresentano fondamentalmente l'archiviazione e sono definiti in termini di valore massimo che possono contenere, non del valore corrente.

L'analogia molto semplice sarebbe una casa - una casa ha una dimensione fissa, indipendentemente da quante persone vivono in essa, e c'è anche un codice di costruzione che stabilisce il numero massimo di persone che possono vivere in una casa di una certa dimensione.

Tuttavia, anche se una sola persona vive in una casa che può ospitare 10 persone, le dimensioni della casa non saranno influenzate dal numero attuale di occupanti.


31
Mi piace l'analogia. Se lo estendiamo un po ', potremmo immaginare di usare un linguaggio di programmazione che non utilizza dimensioni di memoria fisse per i tipi, e che sarebbe come abbattere le stanze della nostra casa ogni volta che non venivano utilizzate e ricostruirle quando necessario (vale a dire tonnellate di spese generali quando potremmo solo costruire un gruppo di case e lasciarle in piedi quando ne abbiamo bisogno).
ahouse101

5
"Perché i tipi rappresentano fondamentalmente l'archiviazione" questo non è vero per tutte le lingue (come il dattiloscritto, ad esempio)
corvus_192,

56
I tag @ corvus_192 hanno un significato. Questa domanda è taggata con C ++, non con "dattiloscritto"
SergeyA

4
@ ahouse101 In effetti, ci sono un certo numero di lingue che hanno numeri interi di precisione illimitata, crescono secondo necessità. Questi linguaggi non richiedono di allocare memoria fissa per variabili, sono implementati internamente come riferimenti a oggetti. Esempi: Lisp, Python.
Barmar,

2
@jamesqf Probabilmente non è una coincidenza che l'aritmetica MP sia stata inizialmente adottata in Lisp, che ha anche gestito automaticamente la memoria. I progettisti hanno ritenuto che l'impatto sulle prestazioni fosse secondario rispetto alla facilità di programmazione. E sono state sviluppate tecniche di ottimizzazione per ridurre al minimo l'impatto.
Barmar,

44

È un'ottimizzazione e una semplificazione.

Puoi avere oggetti di dimensioni fisse. Memorizzando così il valore.
Oppure puoi avere oggetti di dimensioni variabili. Ma memorizzare valore e dimensioni.

oggetti di dimensioni fisse

Il codice che manipola il numero non deve preoccuparsi delle dimensioni. Supponi di utilizzare sempre 4 byte e di rendere il codice molto semplice.

Oggetti di dimensioni dinamiche

Il codice che il numero manipola deve comprendere durante la lettura di una variabile che deve leggere il valore e la dimensione. Utilizzare le dimensioni per assicurarsi che tutti i bit alti siano a zero nel registro.

Quando si ripristina il valore in memoria se il valore non ha superato la sua dimensione corrente, è sufficiente riposizionare il valore in memoria. Ma se il valore si è ridotto o cresciuto, è necessario spostare la posizione di archiviazione dell'oggetto in un'altra posizione in memoria per assicurarsi che non trabocchi. Ora devi tenere traccia della posizione di quel numero (poiché può spostarsi se diventa troppo grande per le sue dimensioni). È inoltre necessario tenere traccia di tutte le posizioni delle variabili non utilizzate in modo che possano essere potenzialmente riutilizzate.

Sommario

Il codice generato per oggetti di dimensioni fisse è molto più semplice.

Nota

La compressione utilizza il fatto che 255 si adatterà a un byte. Esistono schemi di compressione per l'archiviazione di set di dati di grandi dimensioni che utilizzeranno attivamente valori di dimensioni diverse per numeri diversi. Ma dal momento che non si tratta di dati in tempo reale, non si hanno le complessità sopra descritte. Si utilizza meno spazio per archiviare i dati a un costo di compressione / decompressione dei dati per l'archiviazione.


4
Questa è la risposta migliore per me: come tenere traccia delle dimensioni? Con più memoria?
online Thomas,

@ThomasMoors Sì, esattamente: con più memoria. Se ad esempio hai un array dinamico, alcuni intmemorizzeranno il numero di elementi in quell'array. Quello intstesso avrà di nuovo una dimensione fissa.
Alfe,

1
@ThomasMoors ci sono due opzioni comunemente usate, entrambe richiedono memoria aggiuntiva - o hai un campo (dimensione fissa) che ti dice quanti dati ci sono (ad esempio un int per la dimensione dell'array o stringhe "stile pascal" dove la prima L'elemento contiene quanti caratteri ci sono), o in alternativa puoi avere una catena (o una struttura più complessa) in cui ogni elemento in qualche modo nota se è l'ultimo, ad esempio stringhe con terminazione zero o la maggior parte delle forme di elenchi collegati.
Peteris,

27

Perché in un linguaggio come C ++, un obiettivo di progettazione è che le semplici operazioni si compilino in semplici istruzioni della macchina.

Tutti i set di istruzioni CPU tradizionali funzionano con tipi a larghezza fissa e se si desidera eseguire tipi a larghezza variabile , è necessario eseguire più istruzioni macchina per gestirli.

Per quanto riguarda il motivo per cui l'hardware del computer sottostante è in questo modo: è perché è più semplice e più efficiente per molti casi (ma non tutti).

Immagina il computer come un pezzo di nastro:

| xx | xx | xx | xx | xx | xx | xx | xx | xx | xx | xx | xx | xx | ...

Se semplicemente dici al computer di guardare il primo byte sul nastro, xxcome fa a sapere se il tipo si ferma o passa al byte successivo? Se hai un numero come 255(esadecimale FF) o un numero come 65535(esadecimale FFFF) il primo byte è sempre FF.

Quindi come fai a saperlo? Devi aggiungere ulteriore logica e "sovraccaricare" il significato di almeno un bit o valore byte per indicare che il valore continua al byte successivo. Quella logica non è mai "libera", o la si emula nel software o si aggiunge un numero di transistor aggiuntivi alla CPU per farlo.

I tipi di linguaggi a larghezza fissa come C e C ++ lo riflettono.

Non deve essere così, e più linguaggi astratti che sono meno interessati alla mappatura su codice di massima efficienza sono liberi di usare codifiche a larghezza variabile (note anche come "Quantità a lunghezza variabile" o VLQ) per i tipi numerici.

Ulteriori letture: se cerchi "quantità a lunghezza variabile" puoi trovare alcuni esempi di dove quel tipo di codifica è effettivamente efficiente e vale la logica aggiuntiva. Di solito è quando è necessario memorizzare un'enorme quantità di valori che potrebbero trovarsi ovunque all'interno di un ampio intervallo, ma la maggior parte dei valori tende verso un piccolo sottointervallo.


Tieni presente che se un compilatore può dimostrare che può cavarsela memorizzando il valore in una minore quantità di spazio senza rompere alcun codice (ad esempio è una variabile visibile solo internamente all'interno di una singola unità di traduzione) e la sua euristica di ottimizzazione suggerisce che " Sarà più efficiente sull'hardware di destinazione, è completamente consentito di ottimizzarlo di conseguenza e archiviarlo in una quantità di spazio minore, purché il resto del codice funzioni "come se" facesse la cosa standard.

Ma quando il codice deve interagire con un altro codice che potrebbe essere compilato separatamente, le dimensioni devono rimanere coerenti o garantire che ogni parte di codice segua la stessa convenzione.

Perché se non è coerente, c'è questa complicazione: cosa succede se ho int x = 255;ma poi nel codice che faccio x = y? Se intpotrebbe essere di larghezza variabile, il compilatore dovrebbe sapere in anticipo per pre-allocare la massima quantità di spazio di cui avrà bisogno. Questo non è sempre possibile, perché se yun argomento viene passato da un altro pezzo di codice compilato separatamente?


26

Java usa classi chiamate "BigInteger" e "BigDecimal" per fare esattamente questo, così come apparentemente l'interfaccia della classe C ++ GMP di C ++ (grazie a Digital Trauma). Puoi facilmente farlo da solo in quasi tutte le lingue, se lo desideri.

Le CPU hanno sempre avuto la possibilità di utilizzare BCD (codice binario decimale) progettato per supportare operazioni di qualsiasi lunghezza (ma si tende a operare manualmente su un byte alla volta che sarebbe LENTO dagli standard GPU di oggi).

Il motivo per cui non usiamo queste o altre soluzioni simili? Prestazione. I tuoi linguaggi più performanti non possono permettersi di espandere una variabile nel mezzo di alcune operazioni a circuito chiuso: sarebbe molto non deterministico.

Nelle situazioni di archiviazione di massa e trasporto, i valori compressi sono spesso l'UNICO tipo di valore che si utilizzerà. Ad esempio, un pacchetto di musica / video in streaming sul tuo computer potrebbe impiegare un po 'a specificare se il valore successivo è 2 byte o 4 byte come ottimizzazione delle dimensioni.

Una volta che è sul tuo computer dove può essere utilizzato, la memoria è economica ma la velocità e la complicazione delle variabili ridimensionabili non è ... questo è davvero l'unico motivo.


4
Sono contento di vedere qualcuno menzionare BigInteger. Non è un'idea sciocca, è solo che ha senso farlo per numeri estremamente grandi.
Max Barraclough,

1
Per essere pedanti in realtà intendi numeri estremamente precisi :) Beh, almeno nel caso di BigDecimal ...
Bill K,

2
E poiché questo è etichettato c ++ , probabilmente vale la pena menzionare l' interfaccia della classe C ++ GMP , che è la stessa idea di Java * Big *.
Trauma digitale

20

Perché sarebbe molto complicato e il calcolo pesante avere tipi semplici con dimensioni dinamiche. Non sono sicuro che questo sarebbe persino possibile.
Il computer dovrebbe controllare quanti bit prende il numero dopo ogni modifica del suo valore. Sarebbe un sacco di operazioni aggiuntive. E sarebbe molto più difficile eseguire calcoli quando non si conoscono le dimensioni delle variabili durante la compilazione.

Per supportare dimensioni dinamiche di variabili, il computer dovrebbe effettivamente ricordare quanti byte ha una variabile in questo momento che ... richiederebbe memoria aggiuntiva per archiviare tali informazioni. E queste informazioni dovrebbero essere analizzate prima di ogni operazione sulla variabile per scegliere le giuste istruzioni del processore.

Per capire meglio come funziona il computer e perché le variabili hanno dimensioni costanti, apprendi le basi del linguaggio assembler.

Anche se, suppongo che sarebbe possibile ottenere qualcosa di simile con i valori di constexpr. Tuttavia, ciò renderebbe il codice meno prevedibile per un programmatore. Suppongo che alcune ottimizzazioni del compilatore possano fare qualcosa del genere, ma lo nascondono a un programmatore per semplificare le cose.

Ho descritto qui solo i problemi che riguardano l'esecuzione di un programma. Ho omesso tutti i problemi che avrebbero dovuto essere risolti per risparmiare memoria riducendo le dimensioni delle variabili. Onestamente, non penso che sia nemmeno possibile.


In conclusione, l'uso di variabili più piccole di quelle dichiarate ha senso solo se i loro valori sono noti durante la compilazione. È abbastanza probabile che i compilatori moderni lo facciano. In altri casi causerebbe troppi problemi difficili o addirittura irrisolvibili.


Dubito fortemente che una cosa del genere venga fatta durante la compilazione. È inutile conservare in questo modo la memoria del compilatore, e questo è l'unico vantaggio.
Bartek Banachewicz,

1
Stavo pensando piuttosto a operazioni come la moltiplicazione della variabile constexpr per la variabile normale. Ad esempio abbiamo (teoricamente) una variabile constexpr a 8 byte con valore 56e la moltipliciamo per una variabile a 2 byte. Su alcune architetture il funzionamento a 64 bit richiederebbe un calcolo più pesante, quindi il compilatore potrebbe ottimizzarlo per eseguire solo la moltiplicazione a 16 bit.
NO_NAME

Alcune implementazioni APL e alcuni linguaggi della famiglia SNOBOL (SPITBOL penso? Forse Icon) hanno fatto esattamente questo (con granularità): cambiare il formato di rappresentazione in modo dinamico a seconda dei valori effettivi. APL andrebbe da booleano a intero a float e viceversa. SPITBOL passerebbe dalla rappresentazione in colonna dei booleani (8 matrici booleane separate memorizzate in un array di byte) agli interi (IIRC).
davidbak,

16

Quindi myIntoccuperei 4 byte con il mio compilatore. Tuttavia, il valore effettivo 255può essere rappresentato con solo 1 byte, quindi perché myIntnon occupare solo 1 byte di memoria?

Questa è nota come codifica a lunghezza variabile , esistono varie codifiche definite, ad esempio VLQ . Uno dei più famosi, tuttavia, è probabilmente UTF-8 : UTF-8 codifica i punti di codice su un numero variabile di byte, da 1 a 4.

O il modo più generalizzato di chiedere: perché un tipo ha solo una dimensione associata ad esso quando lo spazio richiesto per rappresentare il valore potrebbe essere più piccolo di quella dimensione?

Come sempre in ingegneria, si tratta di compromessi. Non esiste una soluzione che presenti solo vantaggi, quindi è necessario bilanciare vantaggi e compromessi durante la progettazione della soluzione.

Il progetto su cui si basava era di utilizzare tipi fondamentali di dimensioni fisse e l'hardware / i linguaggi volavano da lì.

Quindi, qual è la debolezza fondamentale della codifica variabile , che ha causato il suo rifiuto a favore di più schemi affamati di memoria? Nessun indirizzamento casuale .

Qual è l'indice del byte in corrispondenza del quale inizia il quarto punto di codice in una stringa UTF-8?

Dipende dai valori dei punti di codice precedenti, è necessaria una scansione lineare.

Sicuramente ci sono schemi di codifica a lunghezza variabile che sono migliori nell'indirizzamento casuale?

Sì, ma sono anche più complicati. Se ce n'è uno ideale, non l'ho ancora visto.

L'indirizzamento casuale conta davvero comunque?

Oh si!

Il fatto è che qualsiasi tipo di aggregato / array si basa su tipi di dimensioni fisse:

  • Accesso al 3 ° campo di un struct? Indirizzamento casuale!
  • Accedere al 3 ° elemento di un array? Indirizzamento casuale!

Ciò significa che hai essenzialmente il seguente compromesso:

Tipi di dimensioni fisse O scansioni di memoria lineari


Questo non è un problema tanto quanto lo fai sembrare. Puoi sempre usare le tabelle vettoriali. C'è un sovraccarico di memoria e un ulteriore recupero ma le scansioni lineari non sono necessarie.
Artelius,

2
@Artelius: come si codifica la tabella vettoriale quando gli interi hanno larghezza variabile? Inoltre, qual è l'overhead di memoria della tabella vettoriale durante la codifica di uno per numeri interi che usano da 1 a 4 byte in memoria?
Matthieu M.

Guarda, hai ragione, nell'esempio specifico fornito dall'OP, l'utilizzo di tabelle vettoriali non ha alcun vantaggio. Invece di creare una tabella vettoriale, potresti anche inserire i dati in una matrice di elementi di dimensioni fisse. Tuttavia, il PO ha anche richiesto una risposta più generale. In Python, un array di numeri interi è una tabella vettoriale di numeri interi di dimensioni variabili! Questo non perché risolva questo problema, ma perché Python non sa al momento della compilazione se gli elementi dell'elenco saranno numeri interi, float, dicts, stringhe o liste, che ovviamente hanno tutte dimensioni diverse.
Artelius

@Artelius: Nota che in Python l'array contiene puntatori di dimensione fissa agli elementi; questo rende O (1) arrivare a un elemento, al costo di una indiretta.
Matthieu M.

16

La memoria del computer è suddivisa in blocchi indirizzati consecutivamente di una certa dimensione (spesso 8 bit e denominati byte) e la maggior parte dei computer è progettata per accedere in modo efficiente a sequenze di byte che hanno indirizzi consecutivi.

Se l'indirizzo di un oggetto non cambia mai durante la vita dell'oggetto, allora il codice dato il suo indirizzo può accedere rapidamente all'oggetto in questione. Una limitazione essenziale con questo approccio, tuttavia, è che se un indirizzo viene assegnato per l'indirizzo X, e quindi un altro indirizzo viene assegnato per l'indirizzo Y che è N byte di distanza, allora X non sarà in grado di crescere più grande di N byte durante la vita di Y, a meno che non venga spostato X o Y. Affinché X si sposti, sarebbe necessario che tutto nell'universo che contiene l'indirizzo di X sia aggiornato per riflettere quello nuovo, e allo stesso modo per Y di muoversi. Mentre è possibile progettare un sistema per facilitare tali aggiornamenti (sia Java che .NET lo gestiscono abbastanza bene) è molto più efficiente lavorare con oggetti che rimarranno nella stessa posizione per tutta la loro vita,


"X non sarà in grado di crescere di dimensioni superiori a N byte durante la vita di Y, a meno che X o Y non vengano spostati. Affinché X si sposti, sarebbe necessario che tutto nell'universo che contiene l'indirizzo di X sia aggiornato per riflettere quello nuovo, e allo stesso modo per Y di muoversi ". Questo è il punto saliente dell'IMO: gli oggetti che usano solo le dimensioni necessarie per il loro valore attuale dovrebbero aggiungere tonnellate di costi generali per dimensioni / sentinelle, spostamento della memoria, grafici di riferimento, ecc. E abbastanza ovvio quando si medita su come potrebbe mai funzionare ... ma comunque, vale la pena dichiarare così chiaramente, specialmente come pochi altri.
underscore_d

@underscore_d: Lingue come Javascript progettate da zero per gestire oggetti di dimensioni variabili possono essere incredibilmente efficienti. D'altra parte, mentre è possibile rendere semplici i sistemi di oggetti di dimensioni variabili ed è possibile renderli veloci, le implementazioni semplici sono lente e le implementazioni veloci sono estremamente complesse.
supercat

13

La risposta breve è: perché lo dice lo standard C ++.

La risposta lunga è: ciò che puoi fare su un computer è in definitiva limitato dall'hardware. Naturalmente, è possibile codificare un numero intero in un numero variabile di byte per l'archiviazione, ma poi leggerlo richiede prestazioni speciali della CPU per essere performanti, oppure potresti implementarlo nel software, ma sarebbe terribilmente lento. Le operazioni a dimensione fissa sono disponibili nella CPU per il caricamento di valori di larghezze predefinite, non ce ne sono per larghezze variabili.

Un altro punto da considerare è come funziona la memoria del computer. Supponiamo che il tuo tipo intero possa richiedere da 1 a 4 byte di spazio di archiviazione. Supponiamo che memorizzi il valore 42 nel tuo numero intero: occupa 1 byte e lo posizioni all'indirizzo di memoria X. Quindi memorizzi la tua variabile successiva nella posizione X + 1 (non sto prendendo in considerazione l'allineamento a questo punto) e così via . Successivamente, decidi di modificare il valore in 6424.

Ma questo non rientra in un singolo byte! Allora cosa fai? Dove metti il ​​resto? Hai già qualcosa su X + 1, quindi non puoi metterlo lì. Altrove? Come saprai più tardi dove? La memoria del computer non supporta l'inserimento di semantiche: non puoi semplicemente posizionare qualcosa in una posizione e mettere tutto da parte dopo per fare spazio!

A parte: quello di cui stai parlando è davvero l'area della compressione dei dati. Esistono algoritmi di compressione per comprimere tutto più stretto, quindi almeno alcuni di loro considereranno di non utilizzare più spazio per il tuo numero intero di quello di cui ha bisogno. Tuttavia, i dati compressi non sono facili da modificare (se possibile) e finiscono per essere ricompressi ogni volta che si apportano modifiche.


11

In questo modo si ottengono notevoli vantaggi in termini di prestazioni di runtime. Se dovessi operare su tipi di dimensioni variabili, dovrai decodificare ogni numero prima di eseguire l'operazione (le istruzioni del codice macchina sono in genere a larghezza fissa), eseguire l'operazione, quindi trovare uno spazio in memoria abbastanza grande da contenere il risultato. Quelle sono operazioni molto difficili. È molto più semplice archiviare tutti i dati in modo leggermente inefficiente.

Non è sempre così. Considera il protocollo Protobuf di Google. I protobuf sono progettati per trasmettere dati in modo molto efficiente. Diminuendo il numero di byte trasmessi vale il costo di istruzioni aggiuntive quando si opera sui dati. Di conseguenza, i protobuf utilizzano una codifica che codifica numeri interi in 1, 2, 3, 4 o 5 byte e numeri interi più piccoli occupano meno byte. Una volta ricevuto, tuttavia, il messaggio viene decompresso in un formato intero a dimensione fissa più tradizionale su cui è più facile operare. È solo durante la trasmissione di rete che usano un intero di lunghezza variabile così efficiente in termini di spazio.


11

Mi piace l'analogia della casa di Sergey , ma penso che un'analogia con l'auto sarebbe migliore.

Immagina tipi variabili come tipi di auto e persone come dati. Quando cerchiamo una nuova auto, scegliamo quella più adatta al nostro scopo. Vogliamo una piccola auto intelligente che possa adattarsi solo a una o due persone? O una limousine per trasportare più persone? Entrambi hanno i loro vantaggi e svantaggi come la velocità e il chilometraggio del gas (pensa alla velocità e all'utilizzo della memoria).

Se hai una limousine e stai guidando da solo, non si restringerà per adattarsi solo a te. Per farlo, dovresti vendere l'auto (leggi: deallocate) e comprarne una più piccola per te.

Continuando l'analogia, puoi pensare alla memoria come a un enorme parcheggio pieno di macchine, e quando vai a leggere, un autista specializzato addestrato esclusivamente per il tuo tipo di macchina va a prenderlo per te. Se la tua auto potesse cambiare tipo a seconda delle persone al suo interno, dovrai portare una miriade di autisti ogni volta che vuoi prendere la tua auto poiché non saprebbero mai quale tipo di auto sarà seduta sul posto.

In altre parole, cercare di determinare la quantità di memoria che devi leggere in fase di esecuzione sarebbe estremamente inefficiente e supererebbe il fatto che potresti inserire qualche altra macchina nel tuo parcheggio.


10

Ci sono alcuni motivi Uno è la complessità aggiuntiva per la gestione di numeri di dimensioni arbitrarie e il risultato positivo che ne deriva perché il compilatore non può più ottimizzare in base al presupposto che ogni int sia esattamente lungo X byte.

Un secondo è che la memorizzazione di tipi semplici in questo modo significa che hanno bisogno di un byte aggiuntivo per contenere la lunghezza. Pertanto, un valore di 255 o inferiore richiede effettivamente due byte in questo nuovo sistema, non uno, e nel peggiore dei casi ora sono necessari 5 byte anziché 4. Ciò significa che le prestazioni in termini di memoria utilizzate sono inferiori a quanto si potrebbe pensare e in alcuni casi limite potrebbe effettivamente essere una perdita netta.

Un terzo motivo è che la memoria del computer è generalmente indirizzabile in parole , non in byte. (Ma vedi nota a piè di pagina). Le parole sono un multiplo di byte, generalmente 4 su sistemi a 32 bit e 8 su sistemi a 64 bit. Di solito non puoi leggere un singolo byte, leggi una parola ed estrai l'ennesimo byte da quella parola. Ciò significa che sia l'estrazione di singoli byte da una parola richiede un po 'più di sforzo che la semplice lettura dell'intera parola, sia che sia molto efficace se l'intera memoria è divisa uniformemente in blocchi di dimensioni di parole (cioè di dimensioni di 4 byte). Perché, se hai numeri interi di dimensioni arbitrarie che fluttuano intorno, potresti finire con una parte dell'intero in una parola e un'altra nella parola successiva, che richiede due letture per ottenere l'intero intero.

Nota a piè di pagina: per essere più precisi, mentre vi siete indirizzati in byte, la maggior parte dei sistemi ha ignorato i byte "irregolari". Vale a dire, l'indirizzo 0, 1, 2 e 3 leggono tutti la stessa parola, 4, 5, 6 e 7 leggono la parola successiva e così via.

Su una nota inedita, questo è anche il motivo per cui i sistemi a 32 bit avevano un massimo di 4 GB di memoria. I registri utilizzati per indirizzare le posizioni in memoria sono in genere abbastanza grandi da contenere una parola, ovvero 4 byte, che ha un valore massimo di (2 ^ 32) -1 = 4294967295. 4294967296 byte è 4 GB.


8

Ci sono oggetti che in qualche modo hanno dimensioni variabili, nella libreria standard C ++, come std::vector. Tuttavia, tutti questi allocano dinamicamente la memoria aggiuntiva di cui avranno bisogno. Se lo prendi sizeof(std::vector<int>), otterrai una costante che non ha nulla a che fare con la memoria gestita dall'oggetto e se allochi un array o una struttura contenente std::vector<int>, riserverà questa dimensione di base anziché mettere lo spazio aggiuntivo nello stesso array o struttura . Esistono alcuni elementi della sintassi C che supportano qualcosa del genere, in particolare matrici e strutture a lunghezza variabile, ma C ++ non ha scelto di supportarli.

Lo standard linguistico definisce le dimensioni degli oggetti in questo modo in modo che i compilatori possano generare codice efficiente. Ad esempio, se intcapita che sia lungo 4 byte in alcune implementazioni e tu dichiari acome puntatore a o array di intvalori, allora si a[i]traduce nello pseudocodice, "dereference l'indirizzo a + 4 × i". Questo può essere fatto in tempo costante ed è un'operazione così comune e importante che molte architetture di set di istruzioni, tra cui macchine x86 e DEC PDP su cui è stato originariamente sviluppato C, possono farlo in una singola istruzione macchina.

Un esempio comune del mondo reale di dati memorizzati consecutivamente come unità di lunghezza variabile sono le stringhe codificate come UTF-8. (Tuttavia, il tipo sottostante di una stringa UTF-8 per il compilatore è ancora chare ha larghezza 1. Ciò consente alle stringhe ASCII di essere interpretate come UTF-8 valide e molti codici di libreria come strlen()e strncpy()per continuare a funzionare.) La codifica di qualsiasi punto di codice UTF-8 può essere lunga da uno a quattro byte, quindi, se si desidera il quinto punto di codice UTF-8 in una stringa, potrebbe iniziare in qualsiasi punto dal quinto byte al diciassettesimo byte dei dati. L'unico modo per trovarlo è scansionare dall'inizio della stringa e controllare la dimensione di ciascun punto di codice. Se vuoi trovare il quinto grapheme, devi anche controllare le classi dei personaggi. Se volessi trovare il milionesimo carattere UTF-8 in una stringa, dovrai eseguire questo loop un milione di volte! Se sai che dovrai lavorare spesso con gli indici, puoi attraversare una volta la stringa e costruirne un indice oppure puoi convertirla in una codifica a larghezza fissa, come UCS-4. Trovare il milionesimo carattere UCS-4 in una stringa è solo una questione di aggiungere quattro milioni all'indirizzo dell'array.

Un'altra complicazione con dati di lunghezza variabile è che, quando lo si assegna, è necessario allocare tutta la memoria che potrebbe mai utilizzare, oppure riallocare dinamicamente secondo necessità. Allocare il caso peggiore potrebbe essere estremamente dispendioso. Se è necessario un blocco di memoria consecutivo, la riallocazione potrebbe costringere a copiare tutti i dati in una posizione diversa, ma consentire la memorizzazione della memoria in blocchi non consecutivi complica la logica del programma.

Quindi, è possibile avere bignum lunghezza variabile anziché a larghezza fissa short int, int, long inte long long int, ma sarebbe inefficiente per allocare e utilizzarli. Inoltre, tutte le CPU tradizionali sono progettate per eseguire operazioni aritmetiche su registri a larghezza fissa e nessuna ha istruzioni che operano direttamente su un tipo di bignum a lunghezza variabile. Dovrebbero essere implementati nel software, molto più lentamente.

Nel mondo reale, la maggior parte dei programmatori (ma non tutti) ha deciso che i vantaggi della codifica UTF-8, in particolare la compatibilità, sono importanti e che raramente ci interessa qualcosa di diverso dalla scansione di una stringa dalla parte anteriore a quella posteriore o dalla copia di blocchi di memoria che gli svantaggi della larghezza variabile sono accettabili. Per altre cose, potremmo usare elementi impaccati a larghezza variabile simili a UTF-8. Ma lo facciamo molto raramente e non sono nella libreria standard.


7

Perché un tipo ha solo una dimensione associata ad esso quando lo spazio richiesto per rappresentare il valore potrebbe essere inferiore a quella dimensione?

Principalmente a causa dei requisiti di allineamento.

Secondo basic.align / 1 :

I tipi di oggetto hanno requisiti di allineamento che impongono restrizioni agli indirizzi ai quali può essere assegnato un oggetto di quel tipo.

Pensa a un edificio che ha molti piani e ogni piano ha molte stanze.
Ogni stanza ha le tue dimensioni (uno spazio fisso) in grado di contenere N quantità di persone o oggetti.
Con le dimensioni della stanza conosciute in precedenza, rende la struttura strutturale dell'edificio ben strutturata .

Se le stanze non sono allineate, lo scheletro dell'edificio non sarà ben strutturato.


7

Può essere di meno. Considera la funzione:

int foo()
{
    int bar = 1;
    int baz = 42;
    return bar+baz;
}

si compila in codice assembly (g ++, x64, dettagli rimossi)

$43, %eax
ret

Qui, bare bazfiniamo per usare zero byte per rappresentare.


5

quindi perché myInt non occuperebbe solo 1 byte di memoria?

Perché gli hai detto di usare così tanto. Quando si utilizza un unsigned int, alcuni standard prevedono che verranno utilizzati 4 byte e che l'intervallo disponibile sarà compreso tra 0 e 4.294.967.295. Se invece dovessi usare un unsigned char, probabilmente useresti solo il 1 byte che stai cercando, (a seconda dello standard e C ++ normalmente usa questi standard).

Se non fosse per questi standard dovresti tenerlo a mente: come dovrebbe il compilatore o la CPU sapere di usare solo 1 byte anziché 4? Più avanti nel tuo programma potresti aggiungere o moltiplicare quel valore, il che richiederebbe più spazio. Ogni volta che si effettua un'allocazione di memoria, il sistema operativo deve trovare, mappare e darti quello spazio (potenzialmente scambiando memoria anche nella RAM virtuale); questo può richiedere molto tempo. Se si alloca la memoria in anticipo, non sarà necessario attendere il completamento di un'altra allocazione.

Per quanto riguarda il motivo per cui utilizziamo 8 bit per byte, puoi dare un'occhiata a questo: Qual è la storia del perché i byte sono otto bit?

In una nota a margine, è possibile consentire l'overflow dell'intero; ma se si utilizza un numero intero con segno, gli standard C \ C ++ affermano che gli overflow dei numeri interi comportano un comportamento indefinito. Troppo pieno


5

Qualcosa di semplice che la maggior parte delle risposte sembra mancare:

perché soddisfa gli obiettivi di progettazione di C ++.

Essere in grado di elaborare le dimensioni di un tipo in fase di compilazione consente al compilatore e al programmatore di realizzare un numero enorme di ipotesi di semplificazione, il che comporta numerosi vantaggi, in particolare per quanto riguarda le prestazioni. Naturalmente, i tipi di dimensioni fisse presentano insidie ​​concomitanti come overflow di numeri interi. Ecco perché lingue diverse prendono decisioni di progettazione diverse. (Ad esempio, gli interi Python hanno essenzialmente dimensioni variabili.)

Probabilmente il motivo principale per cui C ++ si appoggia così fortemente ai tipi a dimensione fissa è il suo obiettivo di compatibilità C. Tuttavia, poiché C ++ è un linguaggio tipicamente statico che cerca di generare codice molto efficiente ed evita di aggiungere cose non esplicitamente specificate dal programmatore, i tipi a dimensione fissa hanno ancora molto senso.

Quindi perché C ha optato per i tipi di dimensioni fisse in primo luogo? Semplice. È stato progettato per scrivere sistemi operativi, software server e utility degli anni '70; cose che hanno fornito infrastrutture (come la gestione della memoria) per altri software. A un livello così basso, le prestazioni sono fondamentali, così come il compilatore sta facendo esattamente ciò a cui lo dici.


5

Modificare le dimensioni di una variabile richiederebbe una riallocazione e questo di solito non vale i cicli di CPU aggiuntivi rispetto allo spreco di qualche byte in più di memoria.

Le variabili locali vanno su uno stack che è molto veloce da manipolare quando tali variabili non cambiano di dimensioni. Se hai deciso di voler espandere la dimensione di una variabile da 1 byte a 2 byte, devi spostare tutto nello stack di un byte per creare quello spazio per esso. Ciò può potenzialmente costare molti cicli della CPU a seconda di quante cose devono essere spostate.

Un altro modo in cui puoi farlo è rendere ogni variabile un puntatore a una posizione di heap, ma in realtà in questo modo sprecheresti ancora più cicli e memoria della CPU. I puntatori sono 4 byte (indirizzamento a 32 bit) o ​​8 byte (indirizzamento a 64 bit), quindi si stanno già utilizzando 4 o 8 per il puntatore, quindi la dimensione effettiva dei dati sull'heap. In questo caso, c'è ancora un costo per la riallocazione. Se è necessario riallocare i dati dell'heap, si potrebbe essere fortunati e avere spazio per espanderli in linea, ma a volte è necessario spostarli da qualche altra parte nell'heap per avere il blocco contiguo di memoria della dimensione desiderata.

È sempre più veloce decidere in anticipo la quantità di memoria da utilizzare. Se riesci a evitare il dimensionamento dinamico, ottieni prestazioni. Lo spreco di memoria vale di solito il guadagno in termini di prestazioni. Ecco perché i computer hanno tonnellate di memoria. :)


3

Al compilatore è consentito apportare molte modifiche al codice, purché le cose continuino a funzionare (la regola "così com'è").

Sarebbe possibile usare un'istruzione di spostamento letterale a 8 bit invece del più lungo (32/64 bit) richiesto per spostare un intero int. Tuttavia, occorrerebbero due istruzioni per completare il caricamento, dal momento che è necessario impostare il registro su zero prima di eseguire il caricamento.

È semplicemente più efficiente (almeno secondo i compilatori principali) gestire il valore come 32 bit. In realtà, devo ancora vedere un compilatore x86 / x86_64 che eseguirà il caricamento a 8 bit senza assembly inline.

Tuttavia, le cose sono diverse quando si tratta di 64 bit. Durante la progettazione delle estensioni precedenti (da 16 a 32 bit) dei loro processori, Intel ha commesso un errore. Ecco una buona rappresentazione di come sono. Il principale da asporto qui è che quando scrivi ad AL o AH, l'altro non è influenzato (abbastanza giusto, questo era il punto e allora aveva senso). Ma diventa interessante quando lo espandono a 32 bit. Se si scrivono i bit inferiori (AL, AH o AX), non accade nulla ai 16 bit superiori di EAX, il che significa che se si desidera promuovere un charin unint , devi prima cancellare quella memoria, ma non hai modo di in realtà usando solo questi 16 bit principali, rendendo questa "caratteristica" più una seccatura che altro.

Ora con 64 bit, AMD ha fatto un lavoro molto migliore. Se tocchi qualcosa nei 32 bit inferiori, i 32 bit superiori vengono semplicemente impostati su 0. Questo porta ad alcune ottimizzazioni effettive che puoi vedere in questo godbolt . Puoi vedere che caricare qualcosa di 8 bit o 32 bit avviene allo stesso modo, ma quando usi variabili a 64 bit, il compilatore usa un'istruzione diversa a seconda della dimensione effettiva del tuo letterale.

Quindi puoi vedere qui, i compilatori possono cambiare totalmente la dimensione effettiva della tua variabile all'interno della CPU se produrrebbe lo stesso risultato, ma non ha senso farlo per tipi più piccoli.


correzione: come se . Inoltre, non vedo come, se si potesse utilizzare un carico / archivio più breve, ciò libererebbe gli altri byte per l'uso - il che sembra essere ciò che l'OP si chiede: non solo evitare di toccare la memoria non necessaria dal valore corrente, ma essere in grado di dire quanti byte leggere e spostare magicamente tutta la RAM in fase di runtime, quindi viene soddisfatta una strana idea filosofica di efficienza dello spazio (non importa il costo delle prestazioni gigantesche!) ... Solo avendo vinto le istruzioni di ingombro ridotto "risolverlo". Ciò che una CPU / OS dovrebbe fare sarebbe così complesso da rispondere alla domanda in modo più chiaro IMO.
underscore_d

1
Tuttavia, non è possibile "salvare memoria" nei registri. A meno che tu non stia provando a fare qualcosa di strano abusando di AH e AL, non puoi comunque avere diversi valori nello stesso registro generale. Le variabili locali spesso rimangono nei registri e non passano mai alla RAM se non ce n'è bisogno.
meneldal,
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.