Como as variáveis em C ++ armazenam seu tipo?

41

Se eu definir uma variável de um determinado tipo (o que, até onde eu sei, apenas aloca dados para o conteúdo da variável), como ela rastreia qual tipo de variável ela é?

    
por Finn McClusky 21.10.2018 / 14:39
fonte

5 respostas

105

Variáveis (ou mais geralmente: “objetos” no sentido de C) não armazenam seu tipo em tempo de execução. No que diz respeito ao código da máquina, existe apenas memória não digitada. Em vez disso, as operações nesses dados interpretam os dados como um tipo específico (por exemplo, como um flutuante ou como um ponteiro). Os tipos são usados apenas pelo compilador.

Por exemplo, podemos ter uma estrutura ou classe struct Foo { int x; float y; }; e uma variável Foo f {} . Como um campo pode acessar auto result = f.y; ser compilado? O compilador sabe que f é um objeto do tipo Foo e conhece o layout de Foo -objects. Dependendo dos detalhes específicos da plataforma, isso pode ser compilado como “Pegue o ponteiro para o início de f , adicione 4 bytes, carregue 4 bytes e interprete esses dados como um flutuante”. Em muitos conjuntos de instruções de código de máquina (incl. x86-64) existem diferentes instruções do processador para carregar floats ou ints.

Um exemplo em que o sistema de tipos C ++ não pode controlar o tipo para nós é uma união como union Bar { int as_int; float as_float; } . Uma união contém até um objeto de vários tipos. Se armazenarmos um objeto em uma união, este é o tipo ativo da união. Devemos apenas tentar retirar esse tipo de união, qualquer outra coisa seria um comportamento indefinido. Ou "sabemos" enquanto programamos o tipo ativo, ou podemos criar uma união marcada onde armazenamos uma tag de tipo (geralmente um enum) separadamente. Essa é uma técnica comum em C, mas como temos que manter a união e a tag de tipo em sincronia, isso é bastante propenso a erros. Um ponteiro void* é semelhante a uma união, mas só pode conter objetos de ponteiro, exceto os ponteiros de função.
O C ++ oferece dois mecanismos melhores para lidar com objetos de tipos desconhecidos: Podemos usar técnicas orientadas a objetos para executar o apagamento de tipos (apenas interage com o objeto através de métodos virtuais para que não precisemos conhecer o objeto) tipo real), ou podemos usar std::variant , um tipo de união de tipo seguro.

Há um caso em que o C ++ armazena o tipo de um objeto: se a classe do objeto tem algum método virtual (um “tipo polimórfico”, ou interface). O destino de uma chamada de método virtual é desconhecido em tempo de compilação e é resolvido no tempo de execução com base no tipo dinâmico do objeto ("despacho dinâmico"). A maioria dos compiladores implementa isso armazenando uma tabela de função virtual ("vtable") no início do objeto. A vtable também pode ser usada para obter o tipo do objeto em tempo de execução. Podemos, então, fazer uma distinção entre o tipo estático conhecido de uma expressão em tempo de compilação e o tipo dinâmico de um objeto em tempo de execução.

O C ++ nos permite inspecionar o tipo dinâmico de um objeto com o operador typeid() , o que nos dá um objeto std::type_info . O compilador sabe o tipo do objeto em tempo de compilação ou o compilador armazenou as informações de tipo necessárias dentro do objeto e pode recuperá-lo em tempo de execução.

    
por 21.10.2018 / 15:19
fonte
51

A outra resposta explica bem o aspecto técnico, mas eu gostaria de acrescentar um pouco de como "pensar no código da máquina".

O código da máquina após a compilação é muito burro, e apenas pressupõe que tudo funciona como pretendido. Digamos que você tenha uma função simples como

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

Leva um int e cospe um bool.

Depois de compilá-lo, você pode pensar nisso como algo como esse espremedor de laranja automático:

Leva em laranjas e devolve suco. Reconhece o tipo de objetos que entra? Não, eles são apenas para ser laranjas. O que acontece se receber uma maçã em vez de uma laranja? Talvez isso quebre. Não importa, como um proprietário responsável não tentará usá-lo desta maneira.

A função acima é similar: é projetada para levar ints, e pode quebrar ou fazer algo irrelevante quando alimentado com outra coisa. Isso (normalmente) não importa, porque o compilador (geralmente) verifica que isso nunca acontece - e de fato nunca acontece em código bem formado. Se o compilador detectar a possibilidade de uma função obter um valor digitado incorreto, ela se recusará a compilar o código e retornará erros de tipo.

A advertência é que existem alguns casos de código mal formado que o compilador passará. Exemplos são:

  • conversão incorreta de tipos: conversões explícitas são consideradas corretas e é no programador que ele não está transmitindo void* para orange* quando houver uma maçã na outra extremidade do ponteiro
  • problemas de gerenciamento de memória, como ponteiros nulos, ponteiros pendentes ou uso após o escopo; o compilador não consegue encontrar a maioria deles,
  • Tenho certeza de que há algo mais que estou perdendo.

Como dito, o código compilado é como a máquina juicer - ele não sabe o que ele processa, apenas executa instruções. E se as instruções estão erradas, isso quebra. É por isso que os problemas acima em C ++ resultam em falhas descontroladas.

    
por 21.10.2018 / 18:55
fonte
3

Uma variável tem um número de propriedades fundamentais em uma linguagem como C:

  1. Um nome
  2. Um tipo
  3. Um escopo
  4. Uma vida
  5. Uma localização
  6. Um valor

Em seu código-fonte , o local, (5), é conceitual, e esse local é referido por seu nome, (1). Portanto, uma declaração de variável é usada para criar a localização e o espaço para o valor, (6) e, em outras linhas de origem, nos referimos a esse local e ao valor que ele mantém nomeando a variável em alguma expressão.

Simplificando apenas um pouco, uma vez que seu programa é convertido em código de máquina pelo compilador, a localização (5) é alguma memória ou localização do registrador da CPU, e quaisquer expressões de código fonte que referenciem a variável são traduzidas em seqüências de código de máquina que referenciar a memória ou a localização do registro da CPU.

Assim, quando a tradução é completada e o programa está rodando no processador, os nomes das variáveis são efetivamente esquecidos dentro do código da máquina, e as instruções geradas pelo compilador referem-se apenas às localizações atribuídas às variáveis (ao invés do que com seus nomes). Se você estiver depurando e solicitando a depuração, o local da variável associada ao nome será adicionado aos metadados do programa, embora o processador ainda veja instruções de código de máquina usando locais (não os metadados). (Isso é uma simplificação excessiva, pois alguns nomes estão nos metadados do programa para fins de vinculação, carregamento e pesquisa dinâmica - ainda assim, o processador apenas executa as instruções de código de máquina para o programa e, nesse código de máquina, os nomes têm foram convertidos em locais.)

O mesmo também é verdadeiro para o tipo, escopo e tempo de vida. As instruções de código de máquina geradas pelo compilador conhecem a versão da máquina do local, que armazena o valor. As outras propriedades, como type, são compiladas no código-fonte traduzido como instruções específicas que acessam a localização da variável. Por exemplo, se a variável em questão é um byte de 8 bits vs. um byte de 8 bits, as expressões no código-fonte que fazem referência à variável serão traduzidas em, digamos, cargas de byte assinadas versus cargas de byte não assinadas. conforme necessário para satisfazer as regras da linguagem (C). O tipo da variável é assim codificado na tradução do código-fonte em instruções de máquina, que comandam a CPU como interpretar a memória ou o local do registro da CPU, cada vez que ele usa a localização da variável.

A essência é que temos que dizer à CPU o que fazer por meio de instruções (e mais instruções) no conjunto de instruções de código de máquina do processador. O processador lembra muito pouco sobre o que acabou de fazer ou foi dito - ele apenas executa as instruções dadas, e é o trabalho do programador do compilador ou da linguagem assembly fornecer um conjunto completo de sequências de instruções para manipular as variáveis adequadamente.

Um processador suporta diretamente alguns tipos de dados fundamentais, como byte / word / int / long assinado / não assinado, float, double, etc. O processador geralmente não irá reclamar ou objetar se você tratar alternadamente o mesmo local de memória como assinado ou unsigned, por exemplo, mesmo que normalmente seja um erro lógico no programa. É o trabalho de programação instruir o processador em cada interação com uma variável.

Além desses tipos primitivos fundamentais, temos que codificar as coisas em estruturas de dados e usar algoritmos para manipulá-los em termos dessas primitivas.

Em C ++, os objetos envolvidos na hierarquia de classes para o polimorfismo têm um ponteiro, geralmente no início do objeto, que se refere a uma estrutura de dados específica da classe, o que ajuda com o despacho, a conversão, etc.

Em resumo, o processador não sabe ou lembra o uso pretendido dos locais de armazenamento - ele executa as instruções de código de máquina do programa que lhe dizem como manipular o armazenamento nos registradores da CPU e na memória principal. A programação, então, é o trabalho do software (e dos programadores) de usar o armazenamento de maneira significativa e apresentar um conjunto consistente de instruções de código de máquina ao processador que executa fielmente o programa como um todo.

    
por 21.10.2018 / 23:32
fonte
2

if I define a variable of a certain type how does it keep track of type of variable it is.

Existem duas fases relevantes aqui:

  • Tempo de compilação

O compilador C compila o código C para a linguagem de máquina. O compilador tem todas as informações que ele pode obter do seu arquivo de origem (e bibliotecas, e qualquer outra coisa que precise fazer seu trabalho). O compilador C acompanha o que significa o que. O compilador C sabe que se você declarar uma variável como char , é char.

Ele faz isso usando a chamada "tabela de símbolos", que lista os nomes das variáveis, seu tipo e outras informações. É uma estrutura de dados bastante complexa, mas você pode pensar nela como apenas manter o controle do que os nomes legíveis por humanos significam. Na saída binária do compilador, nenhum nome de variável como este aparece mais (se ignorarmos informações de depuração opcionais que podem ser solicitadas pelo programador).

  • Tempo de execução

A saída do compilador - o executável compilado - é a linguagem de máquina, que é carregada na RAM pelo seu sistema operacional e executada diretamente pela sua CPU. Em linguagem de máquina, não há nenhuma noção de "tipo" - ele só tem comandos que operam em algum local na RAM. Os comandos realmente têm um tipo fixo com o qual operam (isto é, pode haver um comando em linguagem de máquina "adicione esses dois inteiros de 16 bits armazenados em locais de RAM 0x100 e 0x521"), mas não há informações em qualquer lugar no sistema em que os bytes nesses locais realmente estão representando números inteiros. Não há proteção contra erros de tipo em tudo aqui.

    
por 21.10.2018 / 22:17
fonte
1

Existem alguns casos especiais importantes em que o C ++ armazena um tipo em tempo de execução.

A solução clássica é uma união discriminada : uma estrutura de dados que contém um dos vários tipos de objeto, além de um campo que diz que tipo ele contém atualmente. Uma versão com modelo está na biblioteca padrão do C ++ como std::variant . Normalmente, a tag seria um enum , mas se você não precisar de todos os bits de armazenamento para seus dados, pode ser um campo de bits.

O outro caso comum disso é a digitação dinâmica. Quando o class tiver uma função virtual , o programa armazenará um ponteiro para essa função em uma tabela de funções virtuais , que será inicializada para cada instância do class quando for construída . Normalmente, isso significa uma tabela de função virtual para todas as instâncias de classe e cada instância que contém um ponteiro para a tabela apropriada. (Isso economiza tempo e memória porque a tabela será muito maior do que um único ponteiro.) Quando você chama a função virtual através de um ponteiro ou referência, o programa procurará o ponteiro de função na tabela virtual. (Se souber o tipo exato em tempo de compilação, ele poderá pular esta etapa.) Isso permite que o código chame a implementação de um tipo derivado em vez da classe base.

O que torna isso relevante aqui é: cada ofstream contém um ponteiro para a tabela ofstream virtual, cada ifstream para a tabela ifstream virtual e assim por diante. Para hierarquias de classes, o ponteiro da tabela virtual pode servir como a tag que informa ao programa que tipo um objeto de classe possui!

Embora o padrão de idioma não diga às pessoas que projetam compiladores como devem implementar o tempo de execução, é assim que você pode esperar que dynamic_cast e typeof funcionem.

    
por 21.10.2018 / 23:52
fonte