c++ - reales - ¿Por qué los tipos son siempre un tamaño determinado sin importar su valor?
libro de android studio en español pdf (19)
¿Por qué un tipo tiene solo un tamaño asociado cuando el espacio requerido para representar el valor puede ser más pequeño que ese tamaño?
Principalmente debido a los requisitos de alineación.
Según basic.align/1 :
Los tipos de objetos tienen requisitos de alineación que imponen restricciones en las direcciones a las que se puede asignar un objeto de ese tipo.
Piense en un edificio que tiene muchos pisos y cada piso tiene muchas habitaciones.
Cada habitación es de su
tamaño
(un espacio fijo) capaz de contener N cantidad de personas u objetos.
Con el tamaño de la habitación conocido de antemano, hace que el componente estructural del edificio esté
bien estructurado
.
Si las habitaciones no están alineadas, entonces el esqueleto del edificio no estará bien estructurado.
Las implementaciones pueden diferir entre los tamaños reales de los tipos, pero en la mayoría, los tipos como unsigned int y float son siempre de 4 bytes. Pero, ¿por qué un tipo siempre ocupa una cierta cantidad de memoria sin importar su valor? Por ejemplo, si he creado el siguiente entero con el valor de 255
int myInt = 255;
Entonces
myInt
ocuparía 4 bytes con mi compilador.
Sin embargo, el valor real,
255
, se puede representar con solo 1 byte, entonces ¿por qué
myInt
no solo ocupa 1 byte de memoria?
O la forma más general de preguntar: ¿Por qué un tipo tiene solo un tamaño asociado cuando el espacio requerido para representar el valor podría ser más pequeño que ese tamaño?
Entonces, ¿por qué myInt no solo ocupa 1 byte de memoria?
Porque le dijiste que usara tanto.
Cuando se usa una
unsigned int
, algunas normas dictan que se usarán 4 bytes y que el rango disponible para ello será de 0 a 4,294,967,295.
Si
unsigned char
tuviera que
utilizar una
en
su
lugar, probablemente solo usaría el 1 byte que está buscando (según el estándar y C ++ normalmente usa estos estándares).
Si no fuera por estos estándares, tendría que tener esto en cuenta: ¿cómo se supone que el compilador o la CPU deben saber que solo usan 1 byte en lugar de 4? Más adelante en su programa puede agregar o multiplicar ese valor, lo que requeriría más espacio. Cada vez que realice una asignación de memoria, el sistema operativo tiene que buscar, asignar y darle ese espacio, (posiblemente también intercambiar memoria a RAM virtual); Esto puede llevar mucho tiempo. Si asigna la memoria de antemano, no tendrá que esperar a que se complete otra asignación.
En cuanto a la razón por la que usamos 8 bits por byte, puede echar un vistazo a esto: ¿Cuál es el historial de por qué los bytes son ocho bits?
En una nota al margen, puede permitir que el entero se desborde; pero si usa un entero con signo, los estándares de C / C ++ establecen que los desbordamientos de enteros dan como resultado un comportamiento indefinido. Desbordamiento de enteros
Entonces
myInt
ocuparía 4 bytes con mi compilador. Sin embargo, el valor real,255
, se puede representar con solo 1 byte, entonces ¿por quémyInt
no solo ocupa 1 byte de memoria?
Esto se conoce como codificación de longitud variable , hay varias codificaciones definidas, por ejemplo, VLQ . Uno de los más famosos, sin embargo, es probablemente UTF-8 : UTF-8 codifica puntos de código en un número variable de bytes, de 1 a 4.
O la forma más general de preguntar: ¿Por qué un tipo tiene solo un tamaño asociado cuando el espacio requerido para representar el valor podría ser más pequeño que ese tamaño?
Como siempre en ingeniería, todo se trata de compensaciones. No hay una solución que tenga solo ventajas, por lo que debe equilibrar las ventajas y las compensaciones al diseñar su solución.
El diseño que se decidió fue utilizar tipos fundamentales de tamaño fijo, y el hardware / idiomas simplemente se voló desde allí.
Entonces, ¿cuál es la debilidad fundamental de la codificación variable , que hizo que fuera rechazada en favor de más esquemas hambrientos de memoria? Sin direccionamiento aleatorio .
¿Cuál es el índice del byte en el que comienza el cuarto punto de código en una cadena UTF-8?
Depende de los valores de los puntos de código anteriores, se requiere un escaneo lineal.
¿Seguro que hay esquemas de codificación de longitud variable que son mejores en direccionamiento aleatorio?
Sí, pero también son más complicados. Si hay uno ideal, nunca lo he visto todavía.
¿El direccionamiento aleatorio realmente importa de todos modos?
¡Oh si!
La cosa es que cualquier tipo de conjunto / matriz se basa en tipos de tamaño fijo:
-
¿Accediendo al 3er campo de una
struct
? Direccionamiento aleatorio! - ¿Accediendo al tercer elemento de una matriz? Direccionamiento aleatorio!
Lo que significa que esencialmente tiene la siguiente compensación:
Tipos de tamaño fijos o escaneo de memoria lineal
Al compilador se le permite hacer muchos cambios a su código, siempre y cuando las cosas sigan funcionando (la regla "tal como está").
Sería posible usar una instrucción de movimiento literal de 8 bits en lugar del más largo (32/64 bit) requerido para mover una completa
int
.
Sin embargo, necesitaría dos instrucciones para completar la carga, ya que tendría que configurar el registro a cero antes de realizar la carga.
Simplemente es más eficiente (al menos según los compiladores principales) manejar el valor como 32 bits. En realidad, todavía no he visto un compilador x86 / x86_64 que haría una carga de 8 bits sin ensamblaje en línea.
Sin embargo, las cosas son diferentes cuando se trata de 64 bits. Al diseñar las extensiones anteriores (de 16 a 32 bits) de sus procesadores, Intel cometió un error.
Here
hay una buena representación de cómo se ven. La principal conclusión aquí es que cuando escribes en AL o AH, el otro no se ve afectado (bastante bien, ese era el punto y tenía sentido en ese entonces). Pero se pone interesante cuando lo expandieron a 32 bits. Si escribe los bits inferiores (AL, AH o AX), no pasa nada a los 16 bits superiores de EAX, lo que significa que si desea promocionar a
char
en a
int
, primero debe borrar esa memoria, pero no tiene forma de de hecho, solo utilizamos estos 16 bits superiores, lo que hace que esta "característica" sea más dolorosa que cualquier otra cosa.
Ahora con 64 bits, AMD hizo un trabajo mucho mejor. Si toca algo en los 32 bits inferiores, los 32 bits superiores simplemente se configuran en 0. Esto lleva a algunas optimizaciones reales que puede ver en este godbolt . Puedes ver que cargar algo de 8 bits o 32 bits se realiza de la misma manera, pero cuando usas variables de 64 bits, el compilador usa una instrucción diferente dependiendo del tamaño real de tu literal.
Como puede ver aquí, los compiladores pueden cambiar totalmente el tamaño real de su variable dentro de la CPU si produce el mismo resultado, pero no tiene sentido hacerlo para tipos más pequeños.
Algo simple que la mayoría de las respuestas parecen pasar por alto:
Porque se ajusta a los objetivos de diseño de C ++.
El hecho de poder calcular el tamaño de un tipo en el momento de la compilación permite que el compilador y el programador realicen una gran cantidad de supuestos simplificadores, lo que aporta muchos beneficios, especialmente en lo que respecta al rendimiento. Por supuesto, los tipos de tamaño fijo tienen dificultades concomitantes como el desbordamiento de enteros. Es por esto que diferentes idiomas toman diferentes decisiones de diseño. (Por ejemplo, los enteros de Python son esencialmente de tamaño variable).
Probablemente la razón principal por la que C ++ se inclina tan fuertemente a los tipos de tamaño fijo es su objetivo de compatibilidad con C. Sin embargo, dado que C ++ es un lenguaje de tipo estático que intenta generar código muy eficiente y evita agregar cosas que no están especificadas explícitamente por el programador, los tipos de tamaño fijo aún tienen mucho sentido.
Entonces, ¿por qué C optó por tipos de tamaño fijo en primer lugar? Sencillo. Fue diseñado para escribir sistemas operativos, software de servidor y utilidades de los años 70; cosas que proporcionan infraestructura (como la gestión de memoria) para otro software. En un nivel tan bajo, el rendimiento es crítico, y también lo hace el compilador haciendo exactamente lo que usted le dice.
Cambiar el tamaño de una variable requeriría una reasignación y esto generalmente no vale la pena por los ciclos de CPU adicionales en comparación con el desperdicio de algunos bytes más de memoria.
Las variables locales van en una pila que es muy rápida de manipular cuando esas variables no cambian de tamaño. Si decidió que desea expandir el tamaño de una variable de 1 byte a 2 bytes, entonces debe mover todo el contenido de la pila en un byte para hacer ese espacio. Esto puede costar potencialmente muchos ciclos de CPU dependiendo de cuántas cosas se deban mover.
Otra forma de hacerlo es haciendo que cada variable sea un puntero a una ubicación de pila, pero en realidad perdería más ciclos de CPU y memoria. Los punteros son 4 bytes (direccionamiento de 32 bits) u 8 bytes (direccionamiento de 64 bits), por lo que ya está utilizando 4 u 8 para el puntero, luego el tamaño real de los datos en el montón. Todavía hay un costo para la reasignación en este caso. Si necesita reasignar los datos del montón, podría tener suerte y tener espacio para expandirlos en línea, pero a veces tendrá que moverlos a otro lugar del montón para tener el bloque de memoria contiguo del tamaño que desea.
Siempre es más rápido decidir cuánta memoria usar de antemano. Si puedes evitar el dimensionamiento dinámico, obtienes rendimiento. Malgastar la memoria suele valer la pena la ganancia de rendimiento. Es por eso que las computadoras tienen toneladas de memoria. :)
Hay objetos que, en cierto sentido, tienen un tamaño variable, en la biblioteca estándar de C ++, como
std::vector
. Sin embargo, todos estos asignan dinámicamente la memoria extra que necesitarán. Si toma
sizeof(std::vector<int>)
, obtendrá una constante que no tiene nada que ver con la memoria administrada por el objeto, y si asigna una matriz o estructura que contiene
std::vector<int>
, reservará este tamaño base en lugar de colocar el almacenamiento adicional en la misma matriz o estructura. . Hay algunas piezas de sintaxis de C que admiten algo como esto, notablemente arrays y estructuras de longitud variable, pero C ++ no optó por admitirlas.
El estándar de lenguaje define el tamaño del objeto de esa manera para que los compiladores puedan generar código eficiente. Por ejemplo, si
int
pasa a tener una longitud de 4 bytes en alguna implementación, y usted declara
a
como un puntero a una matriz de
int
valores, entonces se
a[i]
traduce en el pseudocódigo, "elimine la referencia a la dirección a + 4 × i". Esto se puede hacer en tiempo constante. y es una operación tan común e importante que muchas arquitecturas de conjuntos de instrucciones, incluidas las máquinas x86 y DEC PDP en las que se desarrolló originalmente C, pueden hacerlo en una sola instrucción de máquina.
Un ejemplo común en el mundo real de los datos almacenados consecutivamente como unidades de longitud variable son las cadenas codificadas como UTF-8. (Sin embargo, el tipo subyacente de una cadena UTF-8 al compilador es inmóvil
char
y tiene un ancho 1. Esto permite que las cadenas ASCII se interpreten como UTF-8 válido, y una gran cantidad de código de biblioteca, como
strlen()
y
strncpy()
para continuar trabajando). La codificación de cualquier punto de código UTF-8 puede tener una longitud de uno a cuatro bytes, y por lo tanto, si desea el quinto punto de código UTF-8 en una cadena, puede comenzar desde el quinto byte hasta el séptimo byte de los datos. La única forma de encontrarlo es escanear desde el principio de la cadena y verificar el tamaño de cada punto de código. Si quieres encontrar el quinto
grafema.
, también es necesario comprobar las clases de caracteres. Si quisieras encontrar el millón de caracteres UTF-8 en una cadena, ¡deberías ejecutar este bucle un millón de veces! Si sabe que necesitará trabajar con índices con frecuencia, puede atravesar la cadena una vez y construir un índice de la misma, o puede convertirla a una codificación de ancho fijo, como UCS-4. Encontrar el millón de caracteres UCS-4 en una cadena es solo una cuestión de agregar cuatro millones a la dirección de la matriz.
Otra complicación con los datos de longitud variable es que, cuando los asigna, necesita asignar la mayor cantidad de memoria que pueda utilizar, o bien reasignar dinámicamente según sea necesario. Asignar para el peor de los casos podría ser un gran desperdicio. Si necesita un bloque de memoria consecutivo, la reasignación podría obligarlo a copiar todos los datos en una ubicación diferente, pero permitir que la memoria se almacene en partes no consecutivas complica la lógica del programa.
Por lo tanto, es posible tener bignums de longitud variable en lugar de ancho fijo
short int
,
int
,
long int
y
long long int
, pero sería ineficaz para asignar y utilizar ellos. Además, todas las CPU principales están diseñadas para realizar operaciones aritméticas en registros de ancho fijo, y ninguna tiene instrucciones que operen directamente en algún tipo de bignum de longitud variable. Esos deberían ser implementados en software, mucho más lentamente.
En el mundo real, la mayoría (pero no todos) los programadores han decidido que los beneficios de la codificación UTF-8, especialmente la compatibilidad, son importantes, y que rara vez nos importa algo más que escanear una cadena de adelante hacia atrás o copiar bloques de Memoria que los inconvenientes del ancho variable son aceptables. Podríamos usar elementos empaquetados de ancho variable similar a UTF-8 para otras cosas. Pero rara vez lo hacemos, y no están en la biblioteca estándar.
Puede ser menos. Considere la función:
int foo()
{
int bar = 1;
int baz = 42;
return bar+baz;
}
se compila al código de ensamblaje (g ++, x64, detalles eliminados)
$43, %eax
ret
Aquí,
bar
y
baz
termine usando cero bytes para representar.
Debido a que en un lenguaje como C ++, un objetivo de diseño es que las operaciones simples se compilen en simples instrucciones de máquina.
Todos los conjuntos de instrucciones principales de la CPU funcionan con tipos de ancho fijo , y si desea realizar tipos de ancho variable , debe realizar varias instrucciones de la máquina para manejarlos.
En cuanto a por qué el hardware de la computadora subyacente es así: es porque es más simple y más eficiente para muchos casos (pero no para todos).
Imagina la computadora como un pedazo de cinta:
| xx | xx | xx | xx | xx | xx | xx | xx | xx | xx | xx | xx | xx | ...
Si simplemente le dice a la computadora que mire el primer byte de la cinta,
xx
, ¿cómo sabe si el tipo se detiene ahí o si continúa hasta el siguiente byte?
Si tiene un número como
255
(
FF
hexadecimal) o un número como
65535
(
FFFF
hexadecimal), el primer byte siempre es
FF
.
Entonces, ¿cómo lo sabes? Debe agregar lógica adicional y "sobrecargar" el significado de al menos un bit o valor de byte para indicar que el valor continúa al siguiente byte. Esa lógica nunca es "libre", ya sea que la emules en el software o agregas un montón de transistores adicionales a la CPU para hacerlo.
Los tipos de lenguajes de ancho fijo como C y C ++ reflejan eso.
No tiene que ser así, y los lenguajes más abstractos que están menos preocupados por la asignación al código de máxima eficiencia son libres de usar codificaciones de ancho variable (también conocidas como "Cantidades de longitud variable" o VLQ) para tipos numéricos.
Lectura adicional: si busca "cantidad de longitud variable", puede encontrar algunos ejemplos de dónde ese tipo de codificación es realmente eficiente y vale la pena por la lógica adicional. Por lo general, es cuando se necesita almacenar una gran cantidad de valores que pueden estar en cualquier lugar dentro de un rango grande, pero la mayoría de los valores tienden hacia un sub-rango pequeño.
Tenga en cuenta que si un compilador puede demostrar que puede escapar almacenando el valor en una cantidad menor de espacio sin romper ningún código (por ejemplo, es una variable solo visible internamente dentro de una sola unidad de traducción), y sus heurísticas de optimización sugieren que '' Seré más eficiente en el hardware de destino, está totalmente permitido optimizarlo en consecuencia y almacenarlo en una cantidad menor de espacio, siempre que el resto del código funcione "como si" hiciera lo estándar.
Pero , cuando el código debe interactuar con otro código que podría compilarse por separado, los tamaños deben ser coherentes o asegurarse de que cada pieza de código siga la misma convención.
Porque si no es consistente, hay una complicación: ¿Qué pasa si tengo
int x = 255;
pero luego en el código hago
x = y
?
Si
int
podría ser de ancho variable, el compilador tendría que saber con anticipación para pre-asignar la cantidad máxima de espacio que necesitará.
Eso no siempre es posible, porque ¿qué
y
si
y
es un argumento que se pasa desde otra pieza de código que se compila por separado?
Debido a que los tipos representan fundamentalmente el almacenamiento, y se definen en términos del valor máximo que pueden contener, no el valor actual.
La analogía muy simple sería una casa: una casa tiene un tamaño fijo, independientemente de cuánta gente viva en ella, y también hay un código de construcción que estipula el número máximo de personas que pueden vivir en una casa de cierto tamaño.
Sin embargo, incluso si una sola persona vive en una casa con capacidad para 10 personas, el tamaño actual de la casa no se verá afectado por el número actual de ocupantes.
Debido a que sería muy complicado y pesado computar tener tipos simples con tamaños dinámicos.
No estoy seguro de que esto sería incluso posible.
La computadora tendría que verificar cuántos bits toma el número después de cada cambio de su valor.
Sería bastante más operaciones adicionales.
Y sería mucho más difícil realizar cálculos cuando no conoce el tamaño de las variables durante la compilación.
Para admitir tamaños dinámicos de variables, la computadora tendría que recordar cuántos bytes tiene una variable en este momento, lo cual ... requeriría memoria adicional para almacenar esa información. Y esta información debería analizarse antes de cada operación en la variable para elegir la instrucción correcta del procesador.
Para comprender mejor cómo funciona la computadora y por qué las variables tienen tamaños constantes, aprenda los conceptos básicos del lenguaje de ensamblador.
Aunque, supongo que sería posible lograr algo así con valores constexpr. Sin embargo, esto haría que el código sea menos predecible para un programador. Supongo que algunas optimizaciones del compilador pueden hacer algo así, pero lo ocultan de un programador para simplificar las cosas.
Aquí describí solo los problemas que se relacionan con el desempeño de un programa. Omití todos los problemas que tendrían que resolverse para ahorrar memoria al reducir el tamaño de las variables. Sinceramente, no creo que sea posible.
En conclusión, usar variables más pequeñas que las declaradas solo tiene sentido si sus valores se conocen durante la compilación. Es bastante probable que los compiladores modernos hagan eso. En otros casos, causaría demasiados problemas difíciles o incluso imposibles de resolver.
Es una optimización y simplificación.
Usted puede tener objetos de tamaño fijo.
Así almacenando el valor.
O puede tener objetos de tamaño variable.
Pero almacenando valor y tamaño.
objetos de tamaño fijo
El código que manipula el número no tiene que preocuparse por el tamaño. Usted asume que siempre usa 4 bytes y hace que el código sea muy simple.
Objetos de tamaño dinámico
El código que el número de manipuladores debe entender al leer una variable debe leer el valor y el tamaño. Use el tamaño para asegurarse de que todos los bits altos estén a cero en el registro.
Cuando vuelva a colocar el valor en la memoria si el valor no ha excedido su tamaño actual, simplemente coloque el valor nuevamente en la memoria. Pero si el valor se ha reducido o aumentado, debe mover la ubicación de almacenamiento del objeto a otra ubicación en la memoria para asegurarse de que no se desborda. Ahora tienes que rastrear la posición de ese número (ya que puede moverse si crece demasiado para su tamaño). También debe hacer un seguimiento de todas las ubicaciones de variables no utilizadas para que puedan ser reutilizadas.
Resumen
El código generado para objetos de tamaño fijo es mucho más simple.
Nota
La compresión utiliza el hecho de que 255 cabrán en un byte. Hay esquemas de compresión para almacenar grandes conjuntos de datos que usarán activamente diferentes valores de tamaño para diferentes números. Pero como no se trata de datos en vivo, no tiene las complejidades descritas anteriormente. Usted usa menos espacio para almacenar los datos a un costo de comprimir / descomprimir los datos para el almacenamiento.
Hay beneficios de rendimiento de tiempo de ejecución bastante importantes al hacer esto. Si tuviera que operar en tipos de tamaño variable, tendría que decodificar cada número antes de realizar la operación (las instrucciones de los códigos de máquina suelen tener un ancho fijo), hacer la operación y luego encontrar un espacio en la memoria lo suficientemente grande como para contener el resultado. Esas son operaciones muy difíciles. Es mucho más fácil simplemente almacenar todos los datos de manera ineficiente.
No siempre es así como se hace. Considere el protocolo Protobuf de Google. Los protobufs están diseñados para transmitir datos de manera muy eficiente. Disminuir el número de bytes transmitidos vale el costo de instrucciones adicionales cuando se opera con los datos. En consecuencia, los protobufs utilizan una codificación que codifica números enteros en 1, 2, 3, 4 o 5 bytes, y los números enteros más pequeños toman menos bytes. Sin embargo, una vez que se recibe el mensaje, se desempaqueta en un formato de enteros de tamaño fijo más tradicional que es más fácil de operar. Es solo durante la transmisión de red que utilizan un entero de longitud variable tan eficiente en espacio.
Hay unas pocas razones. Una es la complejidad agregada para manejar números de tamaño arbitrario y el impacto en el rendimiento que esto proporciona porque el compilador ya no puede optimizar basándose en la suposición de que cada int es de exactamente X bytes.
La segunda es que almacenar tipos simples de esta manera significa que necesitan un byte adicional para mantener la longitud. Por lo tanto, un valor de 255 o menos realmente necesita dos bytes en este nuevo sistema, no uno, y en el peor de los casos, ahora necesita 5 bytes en lugar de 4. Esto significa que la ganancia de rendimiento en términos de memoria utilizada es menor de lo que podría Piense y en algunos casos extremos podría ser realmente una pérdida neta.
Una tercera razón es que la memoria de la computadora es generalmente direccionable en words , no en bytes. (Pero ver nota al pie). Las palabras son un múltiplo de bytes, generalmente 4 en sistemas de 32 bits y 8 en sistemas de 64 bits. Por lo general, no puede leer un byte individual, lee una palabra y extrae el enésimo byte de esa palabra. Esto significa que tanto la extracción de bytes individuales de una palabra requiere un poco más de esfuerzo como leer la palabra completa y que es muy eficiente si la memoria completa está dividida de manera uniforme en fragmentos de tamaño de palabra (es decir, de 4 bytes). Porque, si tiene enteros de tamaño arbitrario flotando alrededor, puede terminar con una parte del entero en una palabra y otra en la siguiente, lo que requiere dos lecturas para obtener el entero completo.
Nota al pie: Para ser más precisos, mientras abordaba en bytes, la mayoría de los sistemas ignoraron los bytes "desiguales". Es decir, la dirección 0, 1, 2 y 3 leen la misma palabra, 4, 5, 6 y 7 leen la siguiente palabra, y así sucesivamente.
En una nota no repetida, esta es también la razón por la que los sistemas de 32 bits tenían un máximo de 4 GB de memoria. Los registros utilizados para abordar las ubicaciones en la memoria son generalmente lo suficientemente grandes como para contener una palabra, es decir, 4 bytes, que tiene un valor máximo de (2 ^ 32) -1 = 4294967295. 4294967296 bytes es de 4 GB.
Java usa clases llamadas "BigInteger" y "BigDecimal" para hacer exactamente esto, al igual que la interfaz de clase GMP C ++ de C ++ al parecer (gracias Digital Trauma). Usted puede hacerlo fácilmente en casi cualquier idioma si lo desea.
Las CPU siempre han tenido la capacidad de usar BCD (decimal codificado en binario) que está diseñado para admitir operaciones de cualquier longitud (pero usted tiende a operar manualmente en un byte a la vez que sería LENTO según los estándares actuales de GPU).
¿La razón por la que no usamos estas u otras soluciones similares? Actuación. Sus lenguajes de mayor rendimiento no pueden darse el lujo de expandir una variable en medio de una operación de bucle cerrado, sería muy no determinista.
En situaciones de almacenamiento masivo y transporte, los valores empaquetados son a menudo el ÚNICO tipo de valor que usaría. Por ejemplo, un paquete de música / video que se transmite a su computadora puede gastar un poco para especificar si el siguiente valor es 2 bytes o 4 bytes como una optimización de tamaño.
Sin embargo, una vez que está en su computadora donde se puede usar, la memoria es barata, pero la velocidad y la complicación de las variables de tamaño no es ... esa es la única razón.
La memoria de la computadora se subdivide en porciones dirigidas consecutivamente de un tamaño determinado (a menudo de 8 bits, y se denomina bytes), y la mayoría de las computadoras están diseñadas para acceder de manera eficiente a secuencias de bytes que tienen direcciones consecutivas.
Si la dirección de un objeto nunca cambia durante la vida útil del objeto, el código dada su dirección puede acceder rápidamente al objeto en cuestión. Sin embargo, una limitación esencial con este enfoque es que si se asigna una dirección para la dirección X, y luego se asigna otra dirección para la dirección Y que está a N bytes de distancia, entonces X no podrá crecer más que N bytes dentro del tiempo de vida de Y, a menos que X o Y se mueva. Para que X se mueva, sería necesario que todo en el universo que contiene la dirección de X se actualice para reflejar la nueva, y también para que Y se mueva. Si bien es posible diseñar un sistema para facilitar dichas actualizaciones (tanto Java como .NET lo administran bastante bien) es mucho más eficiente trabajar con objetos que permanecerán en la misma ubicación durante toda su vida útil, lo que a su vez generalmente requiere que su tamaño deba permanecer constante.
La respuesta corta es: porque el estándar de C ++ lo dice.
La respuesta larga es: lo que puede hacer en una computadora, en última instancia, está limitado por el hardware. Por supuesto, es posible codificar un número entero en un número variable de bytes para el almacenamiento, pero luego leerlo requeriría instrucciones especiales de la CPU para ser eficaz, o podría implementarlo en el software, pero luego sería muy lento. Las operaciones de tamaño fijo están disponibles en la CPU para cargar valores de anchos predefinidos, no hay ninguno para anchos variables.
Otro punto a considerar es cómo funciona la memoria de la computadora. Digamos que su tipo de entero podría ocupar entre 1 y 4 bytes de almacenamiento. Supongamos que almacena el valor 42 en su entero: ocupa 1 byte y lo coloca en la dirección de memoria X. Luego almacena su siguiente variable en la ubicación X + 1 (no estoy considerando la alineación en este punto) y así sucesivamente . Más tarde decides cambiar tu valor a 6424.
¡Pero esto no cabe en un solo byte! Entonces, ¿Qué haces? ¿Dónde pones el resto? Ya tienes algo en X + 1, así que no puedes colocarlo allí. ¿En algún otro lugar? ¿Cómo sabrás después dónde? La memoria de la computadora no admite la inserción de semánticas: ¡no puede simplemente colocar algo en un lugar y empujar todo lo que está a su lado para dejar espacio!
Aparte: de lo que estás hablando es en realidad el área de compresión de datos. Los algoritmos de compresión existen para empaquetar todo con más fuerza, por lo que al menos algunos de ellos considerarán no usar más espacio para su número entero del que necesita. Sin embargo, los datos comprimidos no son fáciles de modificar (si es posible) y solo se vuelven a comprimir cada vez que se realizan cambios.
Me gusta la analogía de la casa de Sergey , pero creo que una analogía de coche sería mejor.
Imagina tipos de variables como tipos de autos y personas como datos. Cuando buscamos un auto nuevo, elegimos el que mejor se adapta a nuestro propósito. ¿Queremos un pequeño automóvil inteligente que solo pueda acomodar a una o dos personas? ¿O una limusina para llevar más gente? Ambos tienen sus ventajas e inconvenientes, como la velocidad y el consumo de gasolina (piense en la velocidad y el uso de la memoria).
Si tienes una limusina y conduces solo, no se reducirá para que solo te quede bien. Para hacer eso, tendrías que vender el auto (leer: desasignar) y comprar uno nuevo más pequeño para ti.
Continuando con la analogía, puedes pensar en la memoria como en un enorme estacionamiento lleno de autos, y cuando vas a leer, un chofer especializado entrenado únicamente para tu tipo de auto va a buscarlo para ti. Si su automóvil pudiera cambiar de tipo dependiendo de las personas que se encuentran dentro de él, tendrá que llevar una gran cantidad de choferes cada vez que desee obtener su automóvil, ya que nunca sabrían qué tipo de automóvil estará sentado en el lugar.
En otras palabras, tratar de determinar la cantidad de memoria que necesita leer en el tiempo de ejecución sería enormemente ineficaz y compensaría el hecho de que tal vez podría colocar algunos autos más en su estacionamiento.
Se supone que el compilador produce ensamblador (y, en última instancia, código de máquina) para alguna máquina, y en general C ++ intenta simpatizar con esa máquina.
Simpatizar con la máquina subyacente significa, en términos generales: facilitar la escritura del código C ++ que se asignará de manera eficiente a las operaciones que la máquina puede ejecutar rápidamente. Por lo tanto, queremos proporcionar acceso a los tipos de datos y operaciones que son rápidos y "naturales" en nuestra plataforma de hardware.
Concretamente, consideremos una arquitectura de máquina específica. Tomemos la actual familia Intel x86.
El Manual del desarrollador de software para arquitecturas Intel® 64 e IA-32 vol 1 ( link ), en la sección 3.4.1 dice:
Los registros de propósito general de 32 bits EAX, EBX, ECX, EDX, ESI, EDI, EBP y ESP se proporcionan para contener los siguientes elementos:
• Operandos para operaciones lógicas y aritméticas.
• Operandos para cálculos de direcciones.
• punteros de memoria
Por lo tanto, queremos que el compilador utilice estos registros EAX, EBX, etc. cuando compile aritmética de enteros C ++ simple.
Esto significa que cuando declaro un
int
, debe ser algo compatible con estos registros, de modo que pueda usarlos de manera eficiente.
Los registros son siempre del mismo tamaño (aquí, 32 bits), por lo que mis variables
int
también serán de 32 bits.
Usaré el mismo diseño (little-endian) para no tener que hacer una conversión cada vez que cargue un valor de variable en un registro, o almacenar un registro de nuevo en una variable.
Usando godbolt podemos ver exactamente lo que hace el compilador por un código trivial:
int square(int num) {
return num * num;
}
compila (con GCC 8.1 y
-fomit-frame-pointer -O3
por simplicidad) para:
square(int):
imul edi, edi
mov eax, edi
ret
esto significa:
-
el parámetro
int num
se pasó en el registro EDI, lo que significa que es exactamente el tamaño y el diseño que Intel espera para un registro nativo. La función no tiene que convertir nada. -
La multiplicación es una sola instrucción (
imul
), que es muy rápida. - devolver el resultado es simplemente una cuestión de copiarlo en otro registro (la persona que llama espera que el resultado se coloque en EAX)
Edición: podemos agregar una comparación relevante para mostrar la diferencia utilizando un diseño no nativo. El caso más simple es almacenar valores en algo distinto al ancho nativo.
Usando godbolt nuevamente, podemos comparar una simple multiplicación nativa
unsigned mult (unsigned x, unsigned y)
{
return x*y;
}
mult(unsigned int, unsigned int):
mov eax, edi
imul eax, esi
ret
con el código equivalente para un ancho no estándar
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
Todas las instrucciones adicionales se refieren a convertir el formato de entrada (dos enteros sin signo de 31 bits) al formato que el procesador puede manejar de forma nativa. Si quisiéramos almacenar el resultado nuevamente en un valor de 31 bits, habría otra o dos instrucciones para hacer esto.
Esta complejidad adicional significa que solo se molestará con esto cuando el ahorro de espacio sea muy importante.
En este caso, solo estamos guardando dos bits en comparación con el uso del tipo
unsigned
nativo o
uint32_t
, que habría generado un código mucho más simple.
Una nota sobre tamaños dinámicos:
El ejemplo anterior sigue siendo valores de ancho fijo en lugar de ancho variable, pero el ancho (y la alineación) ya no coinciden con los registros nativos.
La plataforma x86 tiene varios tamaños nativos, incluidos 8 bits y 16 bits, además de los principales 32 bits (estoy pasando por alto el modo de 64 bits y varias otras cosas por simplicidad).
La arquitectura también admite directamente estos tipos (char, int8_t, uint8_t, int16_t, etc.), en parte por compatibilidad con versiones anteriores con 8086/286/386 / etc. etc. conjuntos de instrucciones.
Es cierto que elegir el tipo de tamaño fijo natural más pequeño que sea suficiente puede ser una buena práctica. Siguen siendo rápidas, se cargan y almacenan instrucciones individuales, aún se obtiene aritmética nativa a toda velocidad e incluso se puede mejorar el rendimiento reduciendo los errores de caché.
Esto es muy diferente a la codificación de longitud variable. He trabajado con algunos de estos, y son horribles. Cada carga se convierte en un bucle en lugar de una sola instrucción. Cada tienda es también un bucle. Cada estructura es de longitud variable, por lo que no puede utilizar matrices de forma natural.
Una nota más sobre la eficiencia.
En comentarios posteriores, ha estado usando la palabra "eficiente", por lo que puedo decir con respecto al tamaño de almacenamiento. En ocasiones, optamos por minimizar el tamaño del almacenamiento; puede ser importante cuando guardamos grandes cantidades de valores en archivos o los enviamos a través de una red. La compensación es que debemos cargar esos valores en registros para hacer cualquier cosa con ellos, y realizar la conversión no es gratis.
Cuando hablamos de eficiencia, necesitamos saber qué estamos optimizando y cuáles son las compensaciones. El uso de tipos de almacenamiento no nativos es una forma de cambiar la velocidad de procesamiento por espacio, y en ocasiones tiene sentido. Al utilizar el almacenamiento de longitud variable (al menos para los tipos aritméticos), se intercambia más velocidad de procesamiento (y la complejidad del código y el tiempo del desarrollador) por un ahorro de espacio adicional a menudo mínimo.
La penalización de velocidad por la que paga esto significa que solo vale la pena cuando necesita minimizar el ancho de banda o el almacenamiento a largo plazo, y en esos casos suele ser más fácil usar un formato simple y natural, y luego simplemente comprimirlo con un sistema de propósito general (como zip, gzip, bzip2, xy o lo que sea).
tl; dr
Cada plataforma tiene una arquitectura, pero puede encontrar un número esencialmente ilimitado de formas diferentes de representar datos. No es razonable que cualquier idioma proporcione una cantidad ilimitada de tipos de datos incorporados. Por lo tanto, C ++ proporciona acceso implícito al conjunto nativo y natural de tipos de datos de la plataforma, y le permite codificar cualquier otra representación (no nativa).