¿Por qué los tipos son siempre de cierto tamaño sin importar su valor?

149

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 creé el siguiente entero con el valor 255

int myInt = 255;

Luego myIntocuparía 4 bytes con mi compilador. Sin embargo, el valor real 255puede representarse con solo 1 byte, entonces ¿por qué myIntno ocuparía solo 1 byte de memoria? O la forma más generalizada de preguntar: ¿por qué un tipo tiene un solo tamaño asociado cuando el espacio requerido para representar el valor puede ser menor que ese tamaño?

Nichlas Uden
fuente
15
1) " Sin embargo, el valor real, 256 puede representarse con solo 1 byte " Incorrecto, el unsingedvalor más grande que puede representarse con 1 byte es 255. 2) Considere la sobrecarga de calcular el tamaño de almacenamiento óptimo y reducir / expandir el área de almacenamiento de una variable a medida que cambia el valor.
Algirdas Preidžius
99
Bueno, cuando llega el momento de leer el valor de la memoria, ¿cómo propones que la máquina determine cuántos bytes leer? ¿Cómo sabrá la máquina dónde dejar de leer el valor? Esto requerirá instalaciones adicionales. Y, en general, la sobrecarga de memoria y rendimiento para estas instalaciones adicionales será mucho mayor que en el caso de simplemente usar 4 bytes fijos para el unsigned intvalor.
ANT
74
Realmente me gusta esta pregunta. Aunque parezca sencillo responderlo, creo que dar una explicación precisa requiere una buena comprensión de cómo funcionan realmente las computadoras y las arquitecturas informáticas. La mayoría de las personas probablemente solo lo darán por sentado, sin tener una explicación completa.
andreee
37
Considere lo que sucedería si agregara 1 al valor de la variable, convirtiéndola en 256, por lo que necesitaría expandirse. ¿A dónde se expande? ¿Mueves el resto de la memoria para hacer espacio? ¿La variable misma se mueve? Si es así, ¿a dónde se mueve y cómo encuentra los punteros que necesita actualizar?
molbdnilo
13
@someidiot no, estás equivocado. std::vector<X>siempre tiene el mismo tamaño, sizeof(std::vector<X>)es decir, es una constante de tiempo de compilación.
SergeyA

Respuestas:

131

Se supone que el compilador produce ensamblador (y en última instancia, código de máquina) para alguna máquina, y generalmente C ++ intenta simpatizar con esa máquina.

Simpatizar con la máquina subyacente significa más o menos: facilitar la escritura de 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, considere una arquitectura de máquina específica. Tomemos la actual familia Intel x86.

El Manual del desarrollador de software de arquitecturas Intel® 64 e IA-32 vol 1 ( enlace ), 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 el cálculo de direcciones.

• Punteros de memoria

Entonces, queremos que el compilador use estos registros EAX, EBX, etc. cuando compila aritmética de enteros simples de C ++. Esto significa que cuando declaro un int, debería ser algo compatible con estos registros, para poder usarlos de manera eficiente.

Los registros son siempre del mismo tamaño (aquí, 32 bits), así que mi int variables siempre serán tambié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 variable en un registro, o almacene un registro nuevamente en una variable.

Usando godbolt podemos ver exactamente qué hace el compilador para algún código trivial:

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

compila (con GCC 8.1 y -fomit-frame-pointer -O3por simplicidad) para:

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

esto significa:

  1. el int numparámetro 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
  2. la multiplicación es una sola instrucción ( imul), que es muy rápida
  3. devolver el resultado es simplemente una cuestión de copiarlo en otro registro (la persona que llama espera que el resultado se ponga en EAX)

Editar: podemos agregar una comparación relevante para mostrar la diferencia usando marcas de diseño no nativas. El caso más simple es almacenar valores en algo diferente 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 están relacionadas con la conversión del 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 hacerlo.

Esta complejidad adicional significa que solo se molestaría con esto cuando el ahorro de espacio es muy importante. En este caso, solo estamos guardando dos bits en comparación con el uso del nativo unsignedo uint32_ttipo, lo 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 del principal de 32 bits (estoy pasando por alto el modo de 64 bits y varias otras cosas por simplicidad).

Estos tipos (char, int8_t, uint8_t, int16_t, etc.) también son directamente compatibles con la arquitectura, en parte por compatibilidad con versiones anteriores de 8086/286/386 / etc. etc. conjuntos de instrucciones.

Sin duda, es el caso que elegir el tamaño fijo natural más pequeño que sea suficiente, puede ser una buena práctica: todavía son rápidas, las instrucciones se cargan y almacenan, aún obtienes aritmética nativa a toda velocidad, e incluso puedes mejorar el rendimiento al Reducción de 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 usar matrices de forma natural.


Una nota adicional sobre eficiencia

En comentarios posteriores, ha estado usando la palabra "eficiente", por lo que puedo decir con respecto al tamaño de almacenamiento. A veces elegimos minimizar el tamaño de almacenamiento; puede ser importante cuando guardamos una gran cantidad de valores en archivos o los enviamos a través de una red. La compensación es que necesitamos cargar esos valores en los registros para hacer algo 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 a veces tiene sentido. El uso de almacenamiento de longitud variable (al menos para tipos aritméticos), intercambia más velocidad de procesamiento (y complejidad de código y tiempo de desarrollador) para un ahorro de espacio a menudo mínimo.

La penalización de velocidad que paga por esto significa que solo vale la pena cuando necesita minimizar absolutamente el ancho de banda o el almacenamiento a largo plazo, y para esos casos generalmente es más fácil usar un formato simple y natural, y luego simplemente comprimirlo con un sistema de uso general (como zip, gzip, bzip2, xy o lo que sea).


tl; dr

Cada plataforma tiene una arquitectura, pero puede crear un número esencialmente ilimitado de formas diferentes de representar datos. No es razonable que ningún idioma proporcione un número ilimitado de tipos de datos integrados. Entonces, C ++ proporciona acceso implícito al conjunto de tipos de datos nativos y naturales de la plataforma, y ​​le permite codificar cualquier otra representación (no nativa) usted mismo.

Inútil
fuente
Estoy mirando todas las respuestas agradables mientras trato de darles sentido a todas ... Entonces, con respecto a su respuesta, ¿no sería un tamaño dinámico, digamos menos de 32 bits para un número entero, no solo permitiría más variables dentro de un registro? ? Si la resistencia es la misma, ¿por qué no sería esto óptimo?
Nichlas Uden
77
@asd pero, ¿cuántos registros usará en el código que calcula cuántas variables están almacenadas actualmente en un registro?
user253751
1
FWIW es común empacar múltiples valores en el espacio más pequeño disponible donde usted decide que el ahorro de espacio es más importante que el costo de velocidad de empacarlos y desempacarlos. Por lo general, no puede operar con ellos de forma natural en su forma empaquetada porque el procesador no sabe cómo hacer la aritmética correctamente en otra cosa que no sean sus registros integrados. Busque BCD para una excepción parcial con soporte de procesador
inútil el
3
Si yo en realidad no necesito los 32 bits para un cierto valor, todavía necesito un lugar para almacenar la longitud, por lo que ahora tengo más de 32 bits en algunos casos.
Inútil
1
+1. Una nota sobre que "el formato simple y natural y luego comprimir" es típicamente mejor: esto es definitivamente cierto en general , pero : para algunos datos, VLQ-cada-valor-luego-comprimir-todo-todo funciona notablemente mejor que simplemente comprimir-el , y para algunas aplicaciones, sus datos no se pueden comprimir juntos , ya que son dispares (como en gitlos metadatos) o realmente los están guardando en la memoria, ocasionalmente necesitan acceder aleatoriamente o modificar algunos, pero no la mayoría los valores (como en los motores de renderizado HTML + CSS) y, por lo tanto, solo se pueden evitar utilizando algo como VLQ in situ.
mtraceur
139

Porque 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ántas personas vivan 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 está viviendo en una casa que puede acomodar a 10, el tamaño de la casa no se verá afectado por el número actual de ocupantes.

SergeyA
fuente
31
Me gusta la analogía. Si lo ampliamos un poco, podríamos imaginar el uso de un lenguaje de programación que no usa tamaños de memoria fijos para los tipos, y eso sería similar a derribar habitaciones en nuestra casa cuando no se usaran, y reconstruirlas cuando sea necesario (es decir, toneladas de gastos generales cuando podríamos construir un montón de casas y dejarlas para cuando las necesitemos).
ahouse101
55
"Debido a que los tipos representan fundamentalmente el almacenamiento" esto no es cierto para todos los idiomas (como el mecanografiado, por ejemplo)
corvus_192
56
Las etiquetas @ corvus_192 tienen significado. Esta pregunta está etiquetada con C ++, no 'mecanografiado'
SergeyA
44
@ ahouse101 De hecho, hay varios idiomas que tienen enteros de precisión ilimitada y crecen según sea necesario. Estos lenguajes no requieren que asigne memoria fija para variables, se implementan internamente como referencias de objeto. Ejemplos: Lisp, Python.
Barmar
2
@jamesqf Probablemente no sea una coincidencia que la aritmética MP se haya adoptado por primera vez en Lisp, que también realizó la gestión automática de la memoria. Los diseñadores sintieron que los impactos en el rendimiento eran secundarios a la facilidad de programación. Y se desarrollaron técnicas de optimización para minimizar el impacto.
Barmar
44

Es una optimización y simplificación.

Puede tener objetos de tamaño fijo. Por lo tanto, almacenar 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 necesita preocuparse por el tamaño. 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 manipulado debe comprender al leer una variable que debe leer el valor y el tamaño. Use el tamaño para asegurarse de que todos los bits altos estén en cero en el registro.

Cuando coloque el valor nuevamente 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 desborde. Ahora tiene que rastrear la posición de ese número (ya que puede moverse si crece demasiado para su tamaño). También debe realizar un seguimiento de todas las ubicaciones de variables no utilizadas para que puedan reutilizarse.

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 encajará en un byte. Existen esquemas de compresión para almacenar grandes conjuntos de datos que utilizará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. Utiliza menos espacio para almacenar los datos a un costo de comprimir / descomprimir los datos para su almacenamiento.

Martin York
fuente
44
Esta es la mejor respuesta para mí: ¿cómo hace un seguimiento del tamaño? ¿Con más memoria?
línea Thomas
@ThomasMoors Sí, exactamente: con más memoria. Si, por ejemplo, tiene una matriz dinámica, algunos intalmacenarán la cantidad de elementos en esa matriz. Eso inttendrá un tamaño fijo nuevamente.
Alfe
1
@ThomasMoors hay dos opciones de uso común, las cuales requieren memoria adicional: tiene un campo (tamaño fijo) que le indica cuántos datos hay (por ejemplo, un int para el tamaño de la matriz o cadenas de "estilo pascal" donde la primera contiene cuántos caracteres hay) o, alternativamente, puede tener una cadena (o una estructura más compleja) donde cada elemento de alguna manera nota si es el último, por ejemplo, cadenas terminadas en cero o la mayoría de las formas de listas enlazadas.
Peteris
27

Porque en un lenguaje como C ++, un objetivo de diseño es que las operaciones simples se compilen en instrucciones simples de la máquina.

Todos los conjuntos de instrucciones de CPU convencionales funcionan con tipos de ancho fijo , y si desea hacer tipos de ancho variable , debe hacer varias instrucciones de 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).

Imagine la computadora como un trozo 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 allí o si pasa al siguiente byte? Si tiene un número como 255(hexadecimal FF) o un número como 65535(hexadecimal FFFF), el primer byte es siempre 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 "gratuita", ya sea que la emule en el software o agregue 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 por qué ser así, y los lenguajes más abstractos que están menos preocupados por la asignación a un 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 los 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 la lógica adicional. Por lo general, es cuando necesita almacenar una gran cantidad de valores que pueden estar en cualquier lugar dentro de un amplio rango, pero la mayoría de los valores tienden a un pequeño subrango.


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 su heurística de optimización sugiere que ' Será más eficiente en el hardware de destino, está completamente permitido optimizarlo en consecuencia y almacenarlo en una cantidad menor de espacio, siempre que el resto del código funcione "como si" hiciera lo normal.

Pero , cuando el código tiene que interactuar con otro código que podría compilarse por separado, los tamaños deben mantenerse consistentes o garantizar que cada parte del código siga la misma convención.

Porque si no es consistente, existe esta complicación: ¿ int x = 255;qué sucede si tengo pero luego en el código que hago x = y? Si intpudiera ser de ancho variable, el compilador tendría que saber de antemano para preasignar la cantidad máxima de espacio que necesitará. Eso no siempre es posible, porque ¿qué ypasa si se pasa un argumento de otro código que se compila por separado?

mtraceur
fuente
26

Java usa clases llamadas "BigInteger" y "BigDecimal" para hacer exactamente esto, al igual que la interfaz de clase GMP C ++ de C ++ aparentemente (gracias Digital Trauma). Puede hacerlo usted mismo en prácticamente 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 tiende a operar manualmente en un byte a la vez, lo 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 permitirse el lujo de expandir una variable en medio de una operación de ciclo cerrado; sería muy no determinista.

En situaciones de almacenamiento y transporte masivo, 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 redimensionables no es ... esa es realmente la única razón.

Bill K
fuente
44
Me alegra ver a alguien mencionar BigInteger. No es que sea una idea tonta, es solo que tiene sentido hacerlo para números extremadamente grandes.
Max Barraclough
1
Para ser pedante, en realidad significa números extremadamente precisos :) Bueno, al menos en el caso de BigDecimal ...
Bill K
2
Y dado que esto está etiquetado como c ++ , probablemente valga la pena mencionar la interfaz de clase GMP C ++ , que es la misma idea que Big * de Java.
Trauma digital
20

Porque sería muy complicado y el cálculo pesado tener tipos simples con tamaños dinámicos. No estoy seguro de que esto sea posible.
La computadora tendría que verificar cuántos bits toma el número después de cada cambio de su valor. Serían muchas operaciones adicionales. Y sería mucho más difícil realizar cálculos cuando no se conocen los tamaños 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 que ... requeriría memoria adicional para almacenar esa información. Y esta información tendría que analizarse antes de cada operación en la variable para elegir la instrucción de procesador correcta.

Para comprender mejor cómo funciona la computadora y por qué las variables tienen tamaños constantes, aprenda los conceptos básicos del lenguaje ensamblador.

Aunque, supongo que sería posible lograr algo así con los valores constexpr. Sin embargo, esto haría que el código fuera menos predecible para un programador. Supongo que algunas optimizaciones del compilador pueden hacer algo así, pero se lo ocultan a un programador para mantener las cosas simples.

Describí aquí solo los problemas relacionados 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. Honestamente, no creo que sea posible.


En conclusión, el uso de variables más pequeñas que las declaradas solo tiene sentido si se conocen sus valores durante la compilación. Es bastante probable que los compiladores modernos hagan eso. En otros casos, causaría demasiados problemas difíciles o incluso irresolubles.

SIN NOMBRE
fuente
Dudo mucho que tal cosa se haga durante el tiempo de compilación. No tiene mucho sentido conservar la memoria del compilador así, y ese es el único beneficio.
Bartek Banachewicz
1
Estaba pensando más bien en operaciones como multiplicar la variable constexpr por la variable normal. Por ejemplo, tenemos (teóricamente) una variable constexpr de 8 bytes con valor 56y la multiplicamos por alguna variable de 2 bytes. En algunas arquitecturas, la operación de 64 bits sería más pesada para el compilador, por lo que el compilador podría optimizar eso para realizar una multiplicación de solo 16 bits.
NO_NAME
Algunas implementaciones de APL y algunos idiomas en la familia SNOBOL (¿creo que SPITBOL? Quizás Icon) hicieron precisamente esto (con granularidad): cambiar el formato de representación dinámicamente dependiendo de los valores reales. APL pasaría de booleano a entero para flotar y regresar. SPITBOL pasaría de la representación de columnas de booleanos (8 matrices booleanas separadas almacenadas en una matriz de bytes) a enteros (IIRC).
davidbak
16

Luego myIntocuparía 4 bytes con mi compilador. Sin embargo, el valor real 255puede representarse con solo 1 byte, entonces ¿por qué myIntno ocuparía solo 1 byte de memoria?

Esto se conoce como codificación de longitud variable , hay varias codificaciones definidas, por ejemplo, VLQ . Sin embargo, uno de los más famosos 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 generalizada de preguntar: ¿por qué un tipo tiene solo un tamaño asociado cuando el espacio requerido para representar el valor puede ser menor que ese tamaño?

Como siempre en ingeniería, se trata de compensaciones. No existe una solución que solo tenga 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 voló desde allí.

Entonces, ¿cuál es el debilidad fundamental de la codificación variable , que provocó su rechazo en favor de más esquemas de memoria? Sin direccionamiento aleatorio .

¿Cuál es el índice del byte en el que el cuarto punto de código comienza en una cadena UTF-8?

Depende de los valores de los puntos de código anteriores, se requiere un escaneo lineal.

¿Seguramente 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 cuestión es que cualquier tipo de agregado / matriz se basa en tipos de tamaño fijo:

  • Accediendo al tercer campo de un 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 fijo O escaneos de memoria lineal

Matthieu M.
fuente
Esto no es tanto un problema como lo haces sonar. Siempre puedes usar tablas de vectores. Hay una sobrecarga de memoria y una recuperación adicional, pero no se necesitan exploraciones lineales.
Artelius
2
@Artelius: ¿Cómo codifica la tabla de vectores cuando los enteros tienen ancho variable? Además, ¿cuál es la sobrecarga de memoria de la tabla de vectores al codificar uno para enteros que usan de 1 a 4 bytes en la memoria?
Matthieu M.
Mira, tienes razón, en el ejemplo específico que dio el OP, el uso de tablas de vectores tiene cero ventajas. En lugar de construir una tabla de vectores, también podría colocar los datos en una matriz de elementos de tamaño fijo. Sin embargo, el OP también solicitó una respuesta más general. ¡En Python, una matriz de enteros es una tabla de vectores de enteros de tamaño variable! Esto no se debe a que resuelva este problema, sino a que Python no sabe en el momento de la compilación si los elementos de la lista serán enteros, flotantes, dictados, cadenas o listas, que tienen diferentes tamaños, por supuesto.
Artelius el
@Artelius: Tenga en cuenta que en Python la matriz contiene punteros de tamaño fijo a los elementos; esto hace que O (1) llegue a un elemento, a costa de una indirección.
Matthieu M.
16

La memoria de la computadora se subdivide en fragmentos direccionados consecutivamente de cierto tamaño (a menudo 8 bits, y denominados bytes), y la mayoría de las computadoras están diseñadas para acceder eficientemente a secuencias de bytes que tienen direcciones consecutivas.

Si la dirección de un objeto nunca cambia dentro de la vida útil del objeto, entonces el código dado 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 de N bytes en la vida útil de Y, a menos que X o Y se muevan. 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 tales actualizaciones (tanto Java como .NET lo manejan bastante bien) es mucho más eficiente trabajar con objetos que permanecerán en la misma ubicación durante toda su vida útil,

Super gato
fuente
"X no podrá crecer más de N bytes dentro de la vida útil de Y, a menos que X o Y se muevan. Para que X se mueva, sería necesario que todo en el universo que contiene la dirección de X se actualice para reflejar el nuevo, y del mismo modo para que Y se mueva ". Este es el punto más destacado de la OMI: los objetos que solo usan tanto tamaño como sus necesidades de valor actual necesitarían agregar toneladas de sobrecarga para tamaños / centinelas, movimiento de memoria, gráficos de referencia, etc. Y bastante obvio cuando uno reflexiona sobre cómo podría funcionar ... pero aún así, vale la pena declararlo tan claramente, especialmente como tan pocos lo hicieron.
underscore_d
@underscore_d: Los lenguajes como Javascript, diseñados desde cero para tratar con objetos de tamaño variable, pueden ser increíblemente eficientes. Por otro lado, si bien es posible simplificar los sistemas de objetos de tamaño variable, y es posible hacerlos rápidos, las implementaciones simples son lentas y las implementaciones rápidas son extremadamente complejas.
supercat
13

La respuesta corta es: Porque el estándar C ++ lo dice.

La respuesta larga es: lo que puede hacer en una computadora está en última instancia 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 que funcionen, o podría implementarlo en el software, pero 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 entero podría ocupar entre 1 y 4 bytes de almacenamiento. Suponga que almacena el valor 42 en su número entero: ocupa 1 byte y lo coloca en la dirección de memoria X. Luego almacena su próxima variable en la ubicación X + 1 (no estoy considerando la alineación en este punto) y así sucesivamente . Más tarde, decide cambiar su 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 más tarde dónde? La memoria de la computadora no admite la semántica de inserción: ¡no puede simplemente colocar algo en un lugar y dejar todo a un lado para dejar espacio!

Aparte: de lo que estás hablando es realmente del área de compresión de datos. Existen algoritmos de compresión para empacar todo más, por lo que al menos algunos de ellos considerarán no usar más espacio para su entero del que necesita. Sin embargo, los datos comprimidos no son fáciles de modificar (si es posible) y simplemente se vuelven a comprimir cada vez que se realizan cambios.

Juan Doe el justo
fuente
11

Al hacer esto, se obtienen beneficios sustanciales de rendimiento en tiempo de ejecución. Si tuviera que operar con tipos de tamaño variable, tendría que decodificar cada número antes de realizar la operación (las instrucciones del código de la máquina suelen ser de ancho fijo), realizar 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 poco eficiente.

Esto no siempre es así. 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 usan una codificación que codifica enteros en 1, 2, 3, 4 o 5 bytes, y los enteros más pequeños toman menos bytes. Sin embargo, una vez que se recibe el mensaje, se desempaqueta en un formato entero de tamaño fijo más tradicional que es más fácil de operar. Es solo durante la transmisión de red que usan un número entero de longitud variable tan eficiente en el espacio.

Cort Ammon
fuente
11

Me gusta la analogía de la casa de Sergey , pero creo que la analogía de un automóvil sería mejor.

Imagine tipos variables como tipos de automóviles y personas como datos. Cuando buscamos un auto nuevo, elegimos el que mejor se adapte a nuestro propósito. ¿Queremos un automóvil inteligente pequeño que solo pueda caber en una o dos personas? ¿O una limusina para transportar más personas? Ambos tienen sus ventajas y desventajas como la velocidad y el consumo de combustible (piense en la velocidad y el uso de la memoria).

Si tiene una limusina y maneja solo, no se reducirá para adaptarse solo a usted. Para hacer eso, tendría que vender el automóvil (léase: desasignar) y comprar uno nuevo más pequeño para usted.

Continuando con la analogía, puede pensar en la memoria como un enorme estacionamiento lleno de automóviles, y cuando va a leer, un chofer especializado entrenado exclusivamente para su tipo de automóvil va a buscarlo. Si su automóvil pudiera cambiar de tipo dependiendo de las personas que lo componen, necesitaría traer una gran cantidad de choferes cada vez que quisiera obtener su automóvil, ya que nunca sabrían qué tipo de automóvil se sentará 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 ineficiente y superaría el hecho de que tal vez podría acomodar algunos autos más en su estacionamiento.

scohe001
fuente
10

Hay unas pocas razones. Una es la complejidad añadida para manejar números de tamaño arbitrario y el impacto en el rendimiento que esto da porque el compilador ya no puede optimizar en base al supuesto de que cada int tiene exactamente X bytes de longitud.

Una 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 el rendimiento ganado en términos de memoria utilizada es menor de lo que podría pensar y, en algunos casos extremos, en realidad podría ser una pérdida neta.

Una tercera razón es que la memoria de la computadora generalmente es direccionable en palabras , no en bytes. (Pero ver nota al pie). Las palabras son múltiplos 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 extraer bytes individuales de una palabra requiere un poco más de esfuerzo que simplemente leer la palabra completa y que es muy eficiente si toda la memoria se divide uniformemente en fragmentos del tamaño de una palabra (es decir, de 4 bytes). Porque, si tiene números enteros de tamaño arbitrario flotando, podría terminar con una parte del número entero en una palabra y otra en la siguiente palabra, que necesitará dos lecturas para obtener el número entero.

Nota al pie de página: para ser más precisos, mientras que usted abordó 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 publicada, esta es también la razón por la cual los sistemas de 32 bits tenían un máximo de 4 GB de memoria. Los registros utilizados para direccionar ubicaciones en la memoria suelen ser 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 son 4 GB.

Buurman
fuente
8

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 adicional que necesitarán. Si lo hace 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 contenga std::vector<int>, se 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, especialmente matrices y estructuras de longitud variable, pero C ++ no eligió 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 inttiene 4 bytes de longitud en alguna implementación, y declara acomo puntero o conjunto de intvalores, entonces se a[i]traduce en el pseudocódigo, "desreferenciar 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 x86 y las máquinas DEC PDP en las que se desarrolló originalmente C, pueden hacerlo en una sola instrucción de máquina.

Un ejemplo común del mundo real de 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 para el compilador es inmóvil chary tiene ancho 1. Esto permite que las cadenas ASCII se interpreten como UTF-8 válidas, y una gran cantidad de código de biblioteca como strlen()y strncpy()que continúen funcionando). 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, podría comenzar desde el quinto byte hasta el decimosé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 debe verificar las clases de caracteres. Si quisieras encontrar el millonésimo personaje UTF-8 en una cadena, ¡necesitarí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 crear un índice, o puede convertirla a una codificación de ancho fijo, como UCS-4. Encontrar el millonésimo carácter UCS-4 en una cadena es solo 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 posible, o bien reasignar dinámicamente según sea necesario. Asignar para el peor de los casos podría ser extremadamente derrochador. 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 fragmentos no consecutivos 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 inty long long int, pero sería ineficaz para asignar y utilizar ellos. Además, todas las CPU convencionales 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 tendrían que implementarse 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 preocupamos por otra cosa que no sea escanear una cadena de adelante hacia atrás o copiar bloques de memoria de que los inconvenientes de ancho variable son aceptables. Podríamos usar elementos empaquetados de ancho variable similares a UTF-8 para otras cosas. Pero rara vez lo hacemos, y no están en la biblioteca estándar.

Davislor
fuente
7

¿Por qué un tipo tiene solo un tamaño asociado cuando el espacio requerido para representar el valor puede ser menor que ese tamaño?

Principalmente debido a los requisitos de alineación.

Según basic.align / 1 :

Los tipos de objeto tienen requisitos de alineación que imponen restricciones a las direcciones en 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 sala 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.

Joseph D.
fuente
7

Puede ser menos. Considere la función:

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

se compila en código de ensamblaje (g ++, x64, detalles eliminados)

$43, %eax
ret

Aquí, bary baztermina usando cero bytes para representar.

max630
fuente
5

Entonces, ¿por qué myInt no solo ocuparía 1 byte de memoria?

Porque le dijiste que usara tanto. Cuando se usa un unsigned int, algunos estándares dictan que se usarán 4 bytes y que el rango disponible será de 0 a 4,294,967,295. Si usara un unsigned charlugar, probablemente solo estaría usando 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 usar solo 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 realiza una asignación de memoria, el sistema operativo tiene que buscar, asignar y darle ese espacio (potencialmente intercambiando memoria a RAM virtual también); 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 ver esto: ¿Cuál es el historial de por qué los bytes son de ocho bits?

En una nota al margen, podría permitir que el número entero se desborde; pero si usa un entero con signo, los estándares C \ C ++ establecen que los desbordamientos de enteros resultan en un comportamiento indefinido. Desbordamiento de enteros

Blerg
fuente
5

Algo simple que la mayoría de las respuestas parecen perder:

porque se adapta a los objetivos de diseño de C ++.

Ser capaz de 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 suposiciones simplificadoras, lo que trae muchos beneficios, particularmente con respecto al rendimiento. Por supuesto, los tipos de tamaño fijo tienen dificultades concomitantes como el desbordamiento de enteros. Es por eso que diferentes lenguajes 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 apoya tan fuertemente en 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 el programador no especifica explícitamente, los tipos de tamaño fijo aún tienen mucho sentido.

Entonces, ¿por qué C optó por los 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 proporcionaron infraestructura (como gestión de memoria) para otro software. A un nivel tan bajo, el rendimiento es crítico, y también lo hace el compilador haciendo exactamente lo que le dices.

Artelius
fuente
5

Cambiar el tamaño de una variable requeriría una reasignación y esto generalmente no vale los ciclos de CPU adicionales en comparación con el desperdicio de unos pocos 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 tiene que mover todo en la pila por un byte para dejar ese espacio. Potencialmente, eso puede costar muchos ciclos de CPU dependiendo de cuántas cosas hay que mover.

Otra forma de hacerlo es convirtiendo cada variable en un puntero a una ubicación de almacenamiento dinámico, pero en realidad desperdiciaría aún más ciclos de CPU y memoria de esta manera. 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 datos de almacenamiento dinámico, puede tener suerte y tener espacio para expandirlos en línea, pero a veces tiene que moverlo a otro lugar del almacenamiento dinámico para tener el bloque de memoria contigua del tamaño que desea.

Siempre es más rápido decidir cuánta memoria usar de antemano. Si puede evitar el tamaño dinámico, obtendrá rendimiento. El desperdicio de memoria generalmente vale la ganancia de rendimiento. Es por eso que las computadoras tienen toneladas de memoria. :)

Chris Rollins
fuente
3

El compilador puede realizar muchos cambios en su código, siempre y cuando las cosas sigan funcionando (la regla "tal como está").

Sería posible utilizar una instrucción de movimiento literal de 8 bits en lugar de la más larga (32/64 bits) requerida para mover un completo int. Sin embargo, necesitaría dos instrucciones para completar la carga, ya que primero tendría que establecer el registro en 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, aún 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. Aquí hay una buena representación de cómo se ven. La conclusión principal aquí es que cuando escribe a 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 con los 16 bits superiores de EAX, lo que significa que si desea promover charunint , primero necesita borrar esa memoria, pero no tiene forma de en realidad solo usa estos 16 bits principales, lo que hace que esta "característica" sea más dolorosa que otra cosa

Ahora con 64 bits, AMD hizo un trabajo mucho mejor. Si tocas algo en los 32 bits inferiores, los 32 bits superiores simplemente se establecen en 0. Esto lleva a algunas optimizaciones reales que puedes ver en este godbolt . Puede ver que cargar algo de 8 bits o 32 bits se hace de la misma manera, pero cuando usa variables de 64 bits, el compilador usa una instrucción diferente dependiendo del tamaño real de su literal.

Como puede ver aquí, los compiladores pueden cambiar totalmente el tamaño real de su variable dentro de la CPU si produciría el mismo resultado, pero no tiene sentido hacerlo para tipos más pequeños.

meneldal
fuente
corrección: como si . Además, no veo cómo, si se pudiera usar una carga / almacenamiento más corto, eso liberaría los otros bytes para su uso, lo que parece ser lo que se pregunta el OP: no solo evitar tocar la memoria que no necesita el valor actual, pero poder saber cuántos bytes leer y cambiar mágicamente toda la RAM en tiempo de ejecución para que se cumpla alguna idea filosófica extraña de la eficiencia del espacio (¡no importa el gigantesco costo de rendimiento!). 't' resolver 'eso. Lo que una CPU / OS necesitaría hacer sería tan complejo que respondería la pregunta más claramente de la OMI.
subrayado_d
1
Sin embargo, realmente no se puede "guardar memoria" en los registros. A menos que esté tratando de hacer algo extraño abusando de AH y AL, de todos modos no puede tener varios valores diferentes en el mismo registro de propósito general. Las variables locales a menudo permanecen en los registros y nunca van a la RAM si no es necesario.
meneldal