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

42

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

Finn McClusky
fuente
8
¿A quién / a qué te refieres con " it " en " cómo hace un seguimiento "? ¿El compilador o la CPU o algo / alguien más le gusta el lenguaje o el programa?
Erik Eidt
8
@ErikEidt IMO el OP obviamente significa "la variable en sí" por "eso". Por supuesto, la respuesta de dos palabras a la pregunta es "no lo hace".
alephzero
2
gran pregunta! especialmente relevante hoy dado todos los idiomas sofisticados que almacenan su tipo.
Trevor Boyd Smith
@alephzero Esa fue obviamente una pregunta importante.
Luaan el

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 cambio, las operaciones en estos datos interpretan los datos como un tipo específico (por ejemplo, como un flotante o como un puntero). Los tipos solo los utiliza 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 auto result = f.y;puede compilar un acceso de campo ? El compilador sabe que fes un objeto de tipo Fooy conoce el diseño de los Fooobjetos. Dependiendo de los detalles específicos de la plataforma, esto podría compilarse como "Tome el puntero al inicio de f, agregue 4 bytes, luego cargue 4 bytes e interprete estos datos como flotantes". En muchos conjuntos de instrucciones de código de máquina (incl. X86-64 ) hay diferentes instrucciones de procesador para cargar flotadores o ints.

Un ejemplo en el que el C ++ sistema de tipo no puede perder de vista el 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 tratar de sacar ese tipo de la unión, cualquier otra cosa sería un comportamiento indefinido. O bien "sabemos" mientras programamos cuál 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 debido a que debemos mantener la unión y la etiqueta de tipo sincronizadas, esto es bastante propenso a errores. Un void*puntero es similar a una unión, pero solo puede contener objetos de puntero, excepto punteros de función.
C ++ ofrece dos mejores mecanismos para tratar objetos de tipos desconocidos: podemos usar técnicas orientadas a objetos para realizar el borrado de tipos (solo interactuar con el objeto a través de métodos virtuales para que no necesitemos saber el tipo real), o podemos uso std::variant, una especie de unión de tipo seguro.

Hay un caso en el que 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 objetivo de una llamada a método virtual es desconocido en el momento de la compilación y se resuelve en tiempo de ejecución en función del tipo dinámico del objeto ("despacho dinámico"). La mayoría de los compiladores implementan esto almacenando una tabla de funciones virtuales ("vtable") al comienzo del objeto. El vtable también se puede usar para obtener el tipo de objeto en tiempo de ejecución. Entonces 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 typeid()operador que nos da un std::type_infoobjeto. O bien el compilador conoce el tipo de objeto en el momento de la compilación, o el compilador ha almacenado la información de tipo necesaria dentro del objeto y puede recuperarla en tiempo de ejecución.

amon
fuente
3
Muy completo.
Deduplicador
99
Tenga en cuenta que para acceder al tipo de un objeto polimórfico, el compilador aún debe saber que el objeto pertenece a una familia de herencia particular (es decir, tener una referencia / puntero escrito al objeto, no void*).
Ruslan
55
+0 porque la primera oración es falsa, los dos últimos párrafos la corrigen.
Marcin
3
En general, lo que se almacena al comienzo de un objeto polimórfico es un puntero a la tabla de método virtual, no a la tabla en sí.
Peter Green
3
@ v.oddou En mi párrafo ignoré algunos detalles. typeid(e)Introspecta el tipo estático de la expresión e. Si el tipo estático es un tipo polimórfico, se evaluará la expresión y se recuperará el tipo dinámico de ese objeto. No puede apuntar typeid a la memoria de tipo desconocido y obtener información útil. Por ejemplo, typeid de una unión describe la unión, no el objeto en la unión. El typeid de a void*es solo un puntero vacío. Y no es posible desreferenciar a void*para llegar a su contenido. En C ++ no hay boxeo a menos que se programe explícitamente de esa manera.
amon
51

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

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

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

Se necesita un int y escupe un bool.

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

exprimidor automático de naranjas

Toma naranjas y devuelve jugo. ¿Reconoce el tipo de objetos en los que se mete? No, se supone que son naranjas. ¿Qué sucede si se obtiene una manzana en lugar de una naranja? Quizás 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 recibir entradas, y puede romperse o hacer algo irrelevante cuando se alimenta con otra cosa. (Generalmente) no importa, porque el compilador (generalmente) 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 de tipo incorrecto, se niega a compilar el código y en su lugar devuelve errores de tipo.

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

  • Tipo de fundición a presión incorrecta: conversiones explícitas se supone que son correctos, y es el programador para asegurarse de que no está lanzando void*a orange*cuando hay una manzana en el otro extremo de la aguja,
  • problemas de administración de memoria, como punteros nulos, punteros colgantes o uso después del alcance; el compilador no puede 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 lo que procesa, solo ejecuta instrucciones. Y si las instrucciones son incorrectas, se rompe. Es por eso que los problemas anteriores en C ++ provocan bloqueos no controlados.

Frax
fuente
44
El compilador intenta verificar que la función pase un objeto del tipo correcto, pero tanto C como C ++ son demasiado complejos para que el compilador lo pruebe en todos los casos. Entonces, su comparación de manzanas y naranjas con el exprimidor es bastante instructiva.
Calchas
@Calchas Gracias por tu comentario! Esta oración fue de hecho una simplificación excesiva. Elaboré un poco sobre los posibles problemas, en realidad están bastante relacionados con la pregunta.
Frax
55
wow gran metáfora para el código de máquina! ¡tu imagen también mejora 10 veces la imagen!
Trevor Boyd Smith
2
"Estoy seguro de que hay algo más que me estoy perdiendo". - ¡Por supuesto! C void*coacciona a foo*las promociones aritméticas habituales, unionescribir tipos, NULLvs. nullptrincluso tener un puntero malo es UB, etc. Pero no creo que enumerar todas esas cosas mejoraría materialmente su respuesta, por lo que probablemente sea mejor irse tal como es.
Kevin
@ Kevin No creo que sea necesario agregar C aquí, ya que la pregunta solo está etiquetada como C ++. Y en C ++ void*no se convierte implícitamente foo*, y el unionpunteo de tipos no es compatible (tiene UB).
Ruslan
3

Una variable tiene varias propiedades fundamentales en un lenguaje como C:

  1. Un nombre
  2. Un tipo
  3. Un alcance
  4. Toda una vida
  5. Una localizació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 contiene al nombrar la variable en alguna expresión.

Simplificando solo un poco, una vez que su programa es traducido al código de máquina por el compilador, la ubicación, (5), es alguna ubicación de registro de memoria o 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 hacen referencia a esa memoria o ubicación del registro de la CPU.

Por lo tanto, cuando se completa la traducción y el programa se está ejecutando 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 de las variables (en lugar de 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 todavía ve las instrucciones del código de la máquina utilizando ubicaciones (no esos metadatos). (Esta es una simplificación excesiva, ya que algunos nombres están en los metadatos del programa con el fin de vincular, cargar y buscar dinámicamente; sin embargo, el procesador solo ejecuta las instrucciones del código de máquina que se le indica para el programa, y ​​en este código de máquina los nombres tienen sido convertido a ubicaciones)

Lo mismo también 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 máquina de la ubicación, que almacena el valor. Las otras propiedades, como 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 con signo versus un byte de 8 bits sin signo, entonces las expresiones en el código fuente que hacen referencia a la variable se traducirán, por ejemplo, en cargas de byte firmado versus cargas de byte 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 le indican a la CPU cómo interpretar la memoria o la ubicación del registro de la CPU cada vez que usa la ubicación de la variable.

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

Un procesador admite directamente algunos tipos de datos fundamentales, como byte / palabra / int / largo firmado / sin signo, flotante, doble, etc. El procesador generalmente no se quejará ni se opondrá si trata alternativamente la misma ubicación de memoria como firmada o sin firmar, por ejemplo, aunque eso normalmente sería un error lógico en el programa. Es el trabajo de la programación 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 esas primitivas.

En C ++, los objetos involucrados en la jerarquía de clases para el polimorfismo tienen un puntero, generalmente al comienzo 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 conoce ni recuerda el uso previsto de las ubicaciones de almacenamiento: ejecuta las instrucciones del código de máquina del programa que le indican cómo manipular el almacenamiento en los registros de la CPU y la memoria principal. La programación, entonces, es el trabajo del software (y los programadores) para usar el almacenamiento de manera significativa y presentar un conjunto consistente de instrucciones de código de máquina al procesador que ejecute fielmente el programa en su conjunto.

Erik Eidt
fuente
1
Tenga cuidado con "cuando se completa la traducción, se olvida el nombre" ... la vinculación se realiza a través de nombres ("símbolo indefinido xy") y bien puede suceder en tiempo de ejecución con la vinculación dinámica. Ver blog.fesnel.com/blog/2009/08/19/… . Sin símbolos de depuración, incluso eliminados: necesita el nombre de la función (y, supongo, variable global) para la vinculación dinámica. De modo que solo se pueden olvidar los nombres de los objetos internos. Por cierto, buena lista de propiedades variables.
Peter - Restablece a Monica el
@ PeterA.Schneider, tiene toda la razón, en el panorama general de las cosas, que los vinculadores y cargadores también participan y usan nombres de funciones y variables (globales) que provienen del código fuente.
Erik Eidt
Una complicación adicional es que algunos compiladores interpretan reglas que, según el Estándar, tienen la intención de permitir que los compiladores asuman que ciertas cosas no tendrán alias, ya que les permite considerar las operaciones que involucran diferentes tipos como no secuenciadas, incluso en casos que no involucran alias como están escritas . Dado algo como useT1(&unionArray[i].member1); useT2(&unionArray[j].member2); useT1(&unionArray[i].member1);, clang y gcc son propensos a suponer que el puntero unionArray[j].member2no puede acceder unionArray[i].member1a pesar de que ambos se derivan de lo mismo unionArray[].
supercat
Ya sea que el compilador interprete la especificación del lenguaje correctamente o no, su trabajo es generar secuencias de instrucciones de código de máquina que lleven a cabo el programa. Esto significa que (optimización de módulo y muchos otros factores) para cada acceso variable en el código fuente tiene que generar algunas instrucciones de código de máquina que le indican al procesador qué tamaño e interpretación de datos usar para la ubicación de almacenamiento. El procesador no recuerda nada acerca de la variable, por lo que cada vez que se supone que debe acceder a la variable, se le debe indicar exactamente cómo hacerlo.
Erik Eidt
2

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

Aquí hay dos fases relevantes:

  • Tiempo de compilación

El compilador de C compila el código de C en 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 declaras que una variable es char, es char.

Lo hace utilizando 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 considerar como un seguimiento de lo que significan los nombres legibles por humanos. En la salida binaria del compilador, ya no aparecen nombres de variables como este (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 lenguaje de máquina, que su sistema operativo carga en la RAM y ejecuta directamente su CPU. En lenguaje de máquina, no existe la noción de "tipo" en absoluto, solo tiene comandos que operan en alguna ubicación en la RAM. De hecho, los comandos tienen un tipo fijo con el que operan (es decir, puede haber un comando en lenguaje de máquina "agregue estos dos enteros de 16 bits almacenados en las ubicaciones de RAM 0x100 y 0x521"), pero no hay información en ningún lugar del sistema que indique que Los bytes en esas ubicaciones en realidad representan números enteros. No hay protección de errores de tipo en absoluto aquí.

AnoE
fuente
Si por casualidad se está refiriendo a C # o Java con "lenguajes orientados a código de bytes", entonces los punteros no se han omitido de ellos; todo lo contrario: los punteros son mucho más comunes en C # y Java (y, en consecuencia, uno de los errores más comunes en Java es la "NullPointerException"). Que se denominen "referencias" es solo una cuestión de terminología.
Peter - Restablece a Monica el
@ PeterA.Schneider, claro, existe la excepción NullPOINTERException, pero hay una distinción muy clara entre una referencia y un puntero en los idiomas que mencioné (como Java, ruby, probablemente C #, incluso Perl hasta cierto punto): la referencia va de la mano. con su sistema de tipos, la recolección de basura, la administración automática de memoria, etc .; Por lo general, ni siquiera es posible establecer explícitamente una ubicación de memoria (como char *ptr = 0x123en C). Creo que mi uso de la palabra "puntero" debería ser bastante claro en este contexto. Si no, no dudes en avisarme y agregaré una oración a la respuesta.
AnoE
los punteros "van de la mano con el sistema de tipos" en C ++ también ;-). (En realidad, los genéricos clásicos de Java están menos tipificados que los de C ++.) La recolección de basura es una característica que C ++ decidió no exigir, pero es posible que una implementación proporcione uno, y no tiene nada que ver con la palabra que usamos para los punteros.
Peter - Restablece a Monica el
OK, @ Peter A. Schneider, realmente no creo que estemos nivelando aquí. He eliminado el párrafo donde mencioné los punteros, de todos modos no hizo nada por la respuesta.
AnoE
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 dice qué tipo contiene actualmente. Una versión con plantilla está 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 escritura dinámica. Cuando classtiene una virtualfunción, el programa almacenará un puntero a esa función en una tabla de funciones virtual , que se inicializará para cada instancia de classcuando se construya. Normalmente, eso significará una tabla de funciones virtuales para todas las instancias de clase, y cada instancia con un puntero a la tabla apropiada. (Esto ahorra tiempo y memoria porque la tabla será mucho más grande que un solo puntero). Cuando llame a esa virtualfunción a través de un puntero o referencia, el programa buscará el puntero de la 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 invoque la implementación de un tipo derivado en lugar de la clase base.

Lo que hace que esto sea relevante aquí es: cada uno ofstreamcontiene un puntero a la ofstreamtabla virtual, cada uno ifstreama la ifstreamtabla virtual, etc. Para las jerarquías de clases, el puntero de tabla virtual puede servir como la etiqueta que le dice al programa qué tipo de objeto tiene una clase.

Aunque el estándar de lenguaje 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 dynamic_casty typeoftrabajar.

Davislor
fuente
"el estándar de lenguaje no le dice a los codificadores" probablemente debería enfatizar que los "codificadores" en cuestión son las personas que escriben gcc, clang, msvc, etc., no las personas que usan esos para compilar su C ++.
Caleth
@Caleth ¡Buena sugerencia!
Davislor