¿Cómo almacenan su tipo las variables en C ++?

41

Si defino una variable de cierto tipo (que, por lo que sé, simplemente asigna datos para el contenido de la variable), ¿cómo hace un seguimiento de qué tipo de variable es?

    
pregunta Finn McClusky 21.10.2018 - 14:39

5 respuestas

105

Las variables (o más generalmente: "objetos" en el sentido de C) no almacenan su tipo en tiempo de ejecución. En lo que respecta al código de máquina, solo hay memoria sin tipo. En su lugar, las operaciones en estos datos interpretan los datos como un tipo específico (por ejemplo, como un flotador o un puntero). Los tipos solo los usa el compilador.

Por ejemplo, podríamos tener una estructura o clase struct Foo { int x; float y; }; y una variable Foo f {} . ¿Cómo se puede compilar un campo de acceso auto result = f.y; ? El compilador sabe que f es un objeto de tipo Foo y conoce el diseño de Foo -objects. Dependiendo de los detalles específicos de la plataforma, esto podría compilarse como "Lleve el puntero al inicio de f , agregue 4 bytes, luego cargue 4 bytes e interprete estos datos como un flotador". En muchos conjuntos de instrucciones de código de máquina (incl. x86-64) hay diferentes instrucciones del procesador para cargar flotadores o ints.

Un ejemplo en el que el sistema de tipos C ++ no puede realizar un seguimiento del tipo para nosotros es una unión como union Bar { int as_int; float as_float; } . Una unión contiene hasta un objeto de varios tipos. Si almacenamos un objeto en una unión, este es el tipo activo de la unión. Solo debemos intentar que ese tipo salga de la unión, cualquier otra cosa sería un comportamiento indefinido. O bien "sabemos" mientras programamos qué es el tipo activo, o podemos crear una unión etiquetada donde almacenamos una etiqueta de tipo (generalmente una enumeración) por separado. Esta es una técnica común en C, pero como tenemos que mantener la unión y la etiqueta de tipo sincronizadas, esto es bastante propenso a errores. Un puntero void* es similar a una unión pero solo puede contener objetos punteros, excepto punteros de función.
C ++ ofrece dos mejores mecanismos para tratar con objetos de tipos desconocidos: podemos usar técnicas orientadas a objetos para realizar borrado de tipo (solo interactuar con el objeto a través de métodos virtuales para que no necesitemos conocer el tipo real), o podemos usar std::variant , un tipo de unión segura para el tipo.

Hay un caso donde C ++ almacena el tipo de un objeto: si la clase del objeto tiene algún método virtual (un "tipo polimórfico", también conocido como interfaz). El destino de una llamada de método virtual es desconocido en tiempo de compilación y se resuelve en tiempo de ejecución según el tipo dinámico del objeto ("envío dinámico"). La mayoría de los compiladores implementan esto almacenando una tabla de funciones virtuales ("vtable") al inicio del objeto. Vtable también se puede utilizar para obtener el tipo de objeto en tiempo de ejecución. Luego podemos hacer una distinción entre el tipo estático conocido de tiempo de compilación de una expresión y el tipo dinámico de un objeto en tiempo de ejecución.

C ++ nos permite inspeccionar el tipo dinámico de un objeto con el operador typeid() que nos da un objeto std::type_info . O bien el compilador conoce el tipo de objeto en tiempo de compilación, o el compilador ha almacenado la información de tipo necesaria dentro del objeto y puede recuperarla en tiempo de ejecución.

    
respondido por el amon 21.10.2018 - 15:19
51

La otra respuesta explica bien el aspecto técnico, pero me gustaría agregar algunos "cómo pensar en el código de máquina".

El código de máquina después de la compilación es bastante tonto, y realmente asume que todo funciona como debe ser. Digamos que tienes una función simple como

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

Toma un int, y escupe un bool.

Después de compilarlo, puedes considerarlo como algo así como este exprimidor automático de naranjas:

Toma naranjas, y devuelve jugo. ¿Reconoce el tipo de objetos que entra? No, se supone que son naranjas. ¿Qué pasa si consigue una manzana en lugar de una naranja? Tal vez se rompa. No importa, ya que un propietario responsable no intentará usarlo de esta manera.

La función anterior es similar: está diseñada para tomar ints y puede romperse o hacer algo irrelevante cuando se alimenta con otra cosa. (Por lo general) no importa, porque el compilador (en general) verifica que nunca suceda, y de hecho nunca sucede en un código bien formado. Si el compilador detecta la posibilidad de que una función obtenga un valor incorrecto, se niega a compilar el código y, en su lugar, devuelve los errores de tipo.

La advertencia es que hay algunos casos de código mal formado que el compilador pasará. Algunos ejemplos son:

  • conversión de tipos incorrecta: se asume que las conversiones explícitas son correctas, y el programador se asegura de que no esté lanzando void* a orange* cuando hay una manzana en el otro extremo del puntero,
  • problemas de administración de memoria, como punteros nulos, punteros colgantes o uso después del alcance; el compilador no es capaz de encontrar la mayoría de ellos,
  • Estoy seguro de que hay algo más que me estoy perdiendo.

Como se dijo, el código compilado es como la máquina exprimidora: no sabe qué procesa, simplemente ejecuta las instrucciones. Y si las instrucciones están equivocadas, se rompe. Es por eso que los problemas anteriores en C ++ resultan en bloqueos incontrolados.

    
respondido por el Frax 21.10.2018 - 18:55
3

Una variable tiene una serie de propiedades fundamentales en un lenguaje como C:

  1. un nombre
  2. Un tipo
  3. Un alcance
  4. Una vida
  5. Una ubicación
  6. Un valor

En su código fuente , la ubicación, (5), es conceptual, y esta ubicación se conoce por su nombre, (1). Entonces, una declaración de variable se usa para crear la ubicación y el espacio para el valor (6), y en otras líneas de origen, nos referimos a esa ubicación y al valor que tiene al nombrar la variable en alguna expresión.

Simplificando solo un poco, una vez que el programa es traducido al código de máquina por el compilador, la ubicación, (5), es algo de memoria o ubicación de registro de CPU, y cualquier expresión de código fuente que haga referencia a la variable se traduce en secuencias de código de máquina que referencia esa memoria o ubicación de registro de CPU.

Por lo tanto, cuando la traducción se completa y el programa se ejecuta en el procesador, los nombres de las variables se olvidan efectivamente dentro del código de la máquina, y las instrucciones generadas por el compilador se refieren solo a las ubicaciones asignadas a las variables (más bien que a sus nombres). Si está depurando y solicitando depuración, la ubicación de la variable asociada con el nombre se agrega a los metadatos para el programa, aunque el procesador aún ve las instrucciones del código de máquina usando ubicaciones (no esos metadatos). (Esto es una simplificación excesiva, ya que algunos nombres se encuentran en los metadatos del programa con el fin de vincularlos, cargarlos y realizar búsquedas dinámicas; aún así, el procesador simplemente ejecuta las instrucciones del código de máquina que se indican para el programa, y en este código de máquina los nombres tienen convertido a ubicaciones.)

Lo mismo es cierto para el tipo, el alcance y la vida útil. Las instrucciones del código de máquina generado por el compilador conocen la versión de la máquina de la ubicación, que almacena el valor. Las otras propiedades, como el tipo, se compilan en el código fuente traducido como instrucciones específicas que acceden a la ubicación de la variable. Por ejemplo, si la variable en cuestión es un byte de 8 bits firmado frente a un byte de 8 bits sin signo, entonces las expresiones en el código fuente que hacen referencia a la variable se traducirán a, digamos, cargas de bytes con signo frente a cargas de bytes sin signo, según sea necesario para satisfacer las reglas del lenguaje (C). El tipo de la variable se codifica así en la traducción del código fuente en instrucciones de la máquina, que ordenan a la CPU cómo interpretar la memoria o la ubicación del registro de la CPU cada vez que utiliza la ubicación de la variable.

La esencia es que tenemos que decirle a la CPU qué hacer a través de las instrucciones (y más instrucciones) en el conjunto de instrucciones del código de máquina del procesador. El procesador recuerda muy poco acerca de lo que acaba de hacer o lo que se le dijo: solo ejecuta las instrucciones proporcionadas, y el compilador o el programador del lenguaje ensamblador tiene la tarea de darle un conjunto completo de secuencias de instrucciones para manipular correctamente las variables.

Un procesador admite directamente algunos tipos de datos fundamentales, como byte / word / int / long signed / unsigned, float, double, etc. El procesador generalmente no se quejará ni se opondrá si, de forma alternativa, trata la misma ubicación de la memoria como firmada o sin firmar, por ejemplo, aunque eso normalmente sería un error lógico en el programa. El trabajo de programación es instruir al procesador en cada interacción con una variable.

Más allá de esos tipos primitivos fundamentales, tenemos que codificar cosas en estructuras de datos y usar algoritmos para manipularlos en términos de esos primitivos.

En C ++, los objetos implicados en la jerarquía de clases para el polimorfismo tienen un puntero, generalmente al principio del objeto, que se refiere a una estructura de datos específica de la clase, que ayuda con el despacho virtual, la conversión, etc.

En resumen, el procesador no sabe ni recuerda el uso previsto de las ubicaciones de almacenamiento: ejecuta las instrucciones en código de máquina del programa que le indican cómo manipular el almacenamiento en los registros de la CPU y en la memoria principal. La programación, entonces, es el trabajo del software (y los programadores) para utilizar el almacenamiento de manera significativa y presentar un conjunto coherente de instrucciones de código de máquina al procesador que ejecuta fielmente el programa en su totalidad.

    
respondido por el Erik Eidt 21.10.2018 - 23:32
2
  

Si defino una variable de cierto tipo, ¿cómo hace un seguimiento del tipo de variable que es?

Hay dos fases relevantes aquí:

  • tiempo de compilación

El compilador de C compila el código C a lenguaje de máquina. El compilador tiene toda la información que puede obtener de su archivo fuente (y bibliotecas, y cualquier otra cosa que necesite para hacer su trabajo). El compilador de C realiza un seguimiento de lo que significa qué. El compilador de C sabe que si declara que una variable es char , es char.

Lo hace usando una llamada "tabla de símbolos" que enumera los nombres de las variables, su tipo y otra información. Es una estructura de datos bastante compleja, pero se puede pensar que es simplemente un seguimiento de lo que significan los nombres legibles por humanos. En la salida binaria del compilador, ya no aparecen nombres de variables como esta (si ignoramos la información de depuración opcional que puede solicitar el programador).

  • Tiempo de ejecución

La salida del compilador, el ejecutable compilado, es un lenguaje de máquina, que se carga en la RAM por su sistema operativo y se ejecuta directamente por su CPU. En el lenguaje de máquina, no hay ninguna noción de "tipo" en absoluto, solo tiene comandos que operan en alguna ubicación en la RAM. Los comandos sí tienen un tipo fijo con el que operan (es decir, puede haber un comando de lenguaje de máquina "agregue estos dos enteros de 16 bits almacenados en las ubicaciones de RAM 0x100 y 0x521"), pero no hay la información en cualquier lugar en el sistema de que los bytes en esas ubicaciones en realidad representan números enteros. No hay protección contra errores de tipo en absoluto aquí.

    
respondido por el AnoE 21.10.2018 - 22:17
1

Hay un par de casos especiales importantes en los que C ++ almacena un tipo en tiempo de ejecución.

La solución clásica es una unión discriminada: una estructura de datos que contiene uno de varios tipos de objetos, más un campo que indica qué tipo contiene actualmente. Una versión con plantilla se encuentra en la biblioteca estándar de C ++ como std::variant . Normalmente, la etiqueta sería un enum , pero si no necesita todos los bits de almacenamiento para sus datos, podría ser un campo de bits.

El otro caso común de esto es la tipificación dinámica. Cuando su class tiene una función virtual , el programa almacenará un puntero a esa función en una tabla de función virtual , que se inicializará para cada instancia del class cuando se construya . Normalmente, eso significará una tabla de funciones virtuales para todas las instancias de clase, y cada instancia tendrá un puntero a la tabla apropiada. (Esto ahorra tiempo y memoria porque la tabla será mucho más grande que un solo puntero). Cuando llama a esa función virtual a través de un puntero o referencia, el programa buscará el puntero de función en la tabla virtual. (Si conoce el tipo exacto en el momento de la compilación, puede omitir este paso). Esto permite que el código llame a la implementación de un tipo derivado en lugar de la clase base.

Lo que hace esto relevante aquí es: cada ofstream contiene un puntero a la tabla virtual ofstream , cada ifstream a la tabla virtual ifstream , y así sucesivamente. Para las jerarquías de clases, el puntero de la tabla virtual puede servir como la etiqueta que le dice al programa qué tipo de objeto de clase tiene

Aunque el estándar de idioma no le dice a las personas que diseñan compiladores cómo deben implementar el tiempo de ejecución bajo el capó, así es como puede esperar que funcionen dynamic_cast y typeof .

    
respondido por el Davislor 21.10.2018 - 23:52

Lea otras preguntas en las etiquetas