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


42

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


8
A quem / a que você está se referindo por " it " em " como ele acompanha "? O compilador ou a CPU ou algo assim / alguém como a linguagem ou o programa?
precisa


8
@ErikEidt IMO o OP obviamente significa "a variável em si" por "it". É claro que a resposta de duas palavras para a pergunta é "isso não acontece".
alephzero

2
ótima pergunta! hoje especialmente relevante, considerando todas as linguagens sofisticadas que armazenam seu tipo.
Trevor Boyd Smith

@alephzero Essa foi obviamente uma pergunta importante.
Luaan 23/10/19

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, há 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 flutuador 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 acesso ao campo pode auto result = f.y;ser compilado? O compilador sabe que fé um objeto do tipo Fooe conhece o layout de Foo-objects. Dependendo dos detalhes específicos da plataforma, isso pode ser compilado como “Coloque o ponteiro no início f, adicione 4 bytes, carregue 4 bytes e interprete esses dados como um flutuador.” Em muitos conjuntos de instruções de código de máquina (incl. X86-64 ), existem instruções diferentes do processador para carregar flutuadores ou ints.

Um exemplo em que o sistema do tipo C ++ não pode acompanhar o tipo para nós é uma união 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, esse é o tipo ativo da união. Devemos apenas tentar retirar esse tipo da união, qualquer outra coisa seria um comportamento indefinido. Ou “sabemos” ao programar qual é o tipo ativo, ou podemos criar uma união com tags onde armazenamos uma tag de tipo (geralmente uma enumeração) separadamente. Essa é uma técnica comum em C, mas como temos que manter a união e a tag de tipo sincronizadas, isso é bastante suscetível a erros. Um void*ponteiro é semelhante a uma união, mas só pode conter objetos de ponteiro, exceto 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 interagir com o objeto por meio de métodos virtuais, para que não precisemos saber o tipo real), ou podemos uso 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 tiver algum método virtual (um "tipo polimórfico", também conhecido como interface). O destino de uma chamada de método virtual é desconhecido no 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 em tempo de compilação de uma expressão e o tipo dinâmico de um objeto em tempo de execução.

C ++ nos permite inspecionar o tipo dinâmico de um objeto com o typeid()operador que nos fornece um std::type_infoobjeto. O compilador conhece o tipo de objeto no momento da compilação ou o compilador armazenou as informações de tipo necessárias dentro do objeto e pode recuperá-las em tempo de execução.


3
Muito abrangente.
Deduplicator

9
Observe que, para acessar o tipo de um objeto polimórfico, o compilador ainda deve saber que o objeto pertence a uma família de herança específica (por exemplo, ter uma referência / ponteiro digitado para o objeto, não void*).
Ruslan #

5
+0 porque a primeira frase é falsa e os dois últimos parágrafos a corrigem.
Marcin

3
Geralmente, o que é armazenado no início de um objeto polimórfico é um ponteiro para a tabela de método virtual, não para a própria tabela.
Peter Green

3
@ v.oddou No meu parágrafo eu ignorei alguns detalhes. typeid(e)introspecta o tipo estático da expressão e. Se o tipo estático for um tipo polimórfico, a expressão será avaliada e o tipo dinâmico desse objeto será recuperado. Você não pode apontar typeid na memória de tipo desconhecido e obter informações úteis. Por exemplo, typeid de uma união descreve a união, não o objeto na união. O tipo de a void*é apenas um ponteiro nulo. E não é possível desreferenciar a void*para obter seu conteúdo. Em C ++, não há boxe, a menos que seja explicitamente programado dessa maneira.
amon

51

A outra resposta explica bem o aspecto técnico, mas eu gostaria de adicionar algumas "como pensar sobre o código da máquina".

O código da máquina após a compilação é bastante estúpido e realmente supõe que tudo funcione conforme o esperado. Digamos que você tenha uma função simples como

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

É preciso um int e cospe um bool.

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

espremedor de laranja automático

Toma laranjas e devolve suco. Reconhece o tipo de objetos em que entra? Não, elas deveriam ser laranjas. O que acontece se receber uma maçã em vez de uma laranja? Talvez isso se quebre. Não importa, pois um proprietário responsável não tentará usá-lo dessa maneira.

A função acima é semelhante: foi projetada para receber entradas, e pode quebrar ou fazer algo irrelevante quando alimentada com outra coisa. (Geralmente) não importa, porque o compilador (geralmente) verifica se 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 errado, ele se recusará a compilar o código e retornará erros de tipo.

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

  • Tipo de fundição incorreto: conversões explícitas são assumidos para ser correto, e é sobre programador para garantir que ele não está lançando void*para orange*quando há uma maçã na outra extremidade do ponteiro,
  • problemas de gerenciamento de memória, como ponteiros nulos, ponteiros pendentes ou uso após escopo; compilador não é capaz de encontrar a maioria deles,
  • Tenho certeza de que há outra coisa que estou perdendo.

Como dito, o código compilado é como a máquina de espremer - não sabe o que processa, apenas executa instruções. E se as instruções estiverem erradas, elas quebram. É por isso que os problemas acima em C ++ resultam em falhas não controladas.


4
O compilador tenta verificar se a função recebeu um objeto do tipo correto, mas C e C ++ são complexos demais para que o compilador o prove em todos os casos. Portanto, sua comparação de maçãs e laranjas com o espremedor é bastante instrutiva.
Calchas 21/10

Obrigado pelo seu comentário! Essa frase era de fato uma simplificação excessiva. Eu elaborei um pouco sobre os possíveis problemas, eles são realmente muito relacionados à questão.
Frax

5
uau grande metáfora para código de máquina! sua metáfora também é 10x melhorada pela imagem!
Trevor Boyd Smith

2
"Tenho certeza de que há outra coisa que estou perdendo." - Claro! Do C void*coage a foo*, as promoções aritméticas habituais, uniontipo trocadilhos, NULLcontra nullptr, mesmo que apenas ter um ponteiro ruim é UB, etc. Mas eu não acho que listando todas essas coisas seria materialmente melhorar a sua resposta, por isso é provavelmente melhor deixar como é.
Kevin

@ Kevin Eu não acho que é necessário adicionar C aqui, já que a pergunta é marcada apenas como C ++. E no C ++ void*não é convertido implicitamente em foo*e o unionpunking de tipo não é suportado (possui UB).
Ruslan #

3

Uma variável possui várias propriedades fundamentais em um idioma como C:

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

No seu código-fonte , o local (5) é conceitual e esse local é chamado pelo 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 essa localização e ao valor que ela possui, 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, o local (5) é algum local de memória ou de registro da CPU, e quaisquer expressões de código-fonte que fazem referência à variável são convertidas em sequências de código de máquina que fazem referência a essa memória ou local de registro da CPU.

Assim, quando a tradução é concluída e o programa está em execução no processador, os nomes das variáveis ​​são efetivamente esquecidos no código da máquina e as instruções geradas pelo compilador referem-se apenas aos locais atribuídos às variáveis ​​(e não aos seus nomes). Se você estiver depurando e solicitando 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 esses 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 executa as instruções do código da máquina para o programa, e nesse código da máquina os nomes têm convertidos em locais.)

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

A essência é que precisamos dizer à CPU o que fazer através 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 do que acabou de fazer ou foi informado - ele executa apenas as instruções fornecidas, e é tarefa do programador da compilação ou da linguagem assembly fornecer um conjunto completo de seqüências de instruções para manipular variáveis ​​adequadamente.

Um processador suporta diretamente alguns tipos de dados fundamentais, como byte / word / int / assinatura longa / sem assinatura, float, double, etc. Por exemplo, mesmo que isso normalmente seja um erro lógico no programa. O trabalho da programação é instruir o processador a cada interação com uma variável.

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

No 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, que ajuda no despacho virtual, na conversão, etc.

Em resumo, o processador não conhece ou lembra o uso pretendido dos locais de armazenamento - ele executa as instruções de código de máquina do programa que informam como manipular o armazenamento nos registros da CPU e na memória principal. A programação, portanto, é o trabalho do software (e 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.


11
Cuidado com "quando a tradução é concluída, o nome é esquecido" ... a vinculação é feita por meio de nomes ("símbolo indefinido xy") e pode muito bem acontecer em tempo de execução com a vinculação dinâmica. Consulte blog.fesnel.com/blog/2009/08/19/… . Nenhum símbolo de depuração, nem mesmo removido: você precisa do nome da função (e, presumo, variável global) para vinculação dinâmica. Portanto, apenas nomes de objetos internos podem ser esquecidos. A propósito, boa lista de propriedades variáveis.
Peter - Restabelece Monica

@ PeterA.Schneider, você está absolutamente certo, em termos gerais, de que vinculadores e carregadores também participam e usam nomes de funções (globais) e variáveis ​​que vieram do código-fonte.
Erik Eidt 22/10

Uma complicação adicional é que alguns compiladores interpretam regras que, de acordo com o Padrão, pretendem permitir que os compiladores assumam que certas coisas não serão apelidadas, permitindo que considerem operações que envolvem tipos diferentes como não sequenciadas, mesmo em casos que não envolvam apelidos conforme escritos . Dado algo como useT1(&unionArray[i].member1); useT2(&unionArray[j].member2); useT1(&unionArray[i].member1);, clang e gcc tendem a supor que o ponteiro unionArray[j].member2não pode acessar, unionArray[i].member1mesmo que ambos sejam derivados do mesmo unionArray[].
Supercat 22/10

Quer o compilador interprete a especificação da linguagem corretamente ou não, seu trabalho é gerar seqüências de instruções de código de máquina que executam o programa. Isso significa que (otimização do módulo e muitos outros fatores) para cada acesso variável no código fonte, ele deve gerar algumas instruções de código de máquina que informam ao processador qual tamanho e interpretação de dados usar para o local de armazenamento. O processador não se lembra de nada sobre a variável, portanto, toda vez que deve acessar a variável, ele precisa ser instruído exatamente como fazê-lo.
Erik Eidt 22/10

2

se eu definir uma variável de um determinado tipo, como ela mantém o controle do tipo de variável que é.

Existem duas fases relevantes aqui:

  • Tempo de compilação

O compilador C compila o código C na 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 ele precise fazer seu trabalho). O compilador C acompanha o que significa o que. O compilador C sabe que, se você declarar uma variável char, ela é char.

Isso é feito 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 nisso como apenas acompanhar o que significam os nomes legíveis por humanos. Na saída binária do compilador, nenhum nome de variável como esse 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 da máquina, carregada na RAM pelo sistema operacional e executada diretamente pela CPU. Na linguagem de máquina, não existe nenhuma noção de "tipo" - ele possui apenas comandos que operam em algum local da RAM. Os comandos realmente têm um tipo fixo com o qual operam (por exemplo, pode haver um comando de linguagem de máquina "adicione esses dois números inteiros de 16 bits armazenados nos locais de RAM 0x100 e 0x521"), mas não há informações em nenhum lugar do sistema que o bytes nesses locais realmente representam números inteiros. Não há proteção contra erros de tipo em tudo aqui.


Se, por acaso, você estiver se referindo a C # ou Java com "linguagens orientadas a código de bytes", os ponteiros nunca os omitiram; muito pelo contrário: os ponteiros são muito mais comuns em C # e Java (e, consequentemente, um dos erros mais comuns em Java é a "NullPointerException"). O fato de serem denominados "referências" é apenas uma questão de terminologia.
Peter - Restabelece Monica

@ PeterA.Schneider, claro, existe a NullPOINTERException, mas há uma distinção muito definida entre uma referência e um ponteiro nas linguagens que mencionei (como Java, ruby, provavelmente C # e até Perl, até certo ponto) - a referência caminha junto com seu sistema de tipos, coleta de lixo, gerenciamento automático de memória etc .; geralmente nem é possível declarar explicitamente um local de memória (como char *ptr = 0x123em C). Eu acredito que meu uso da palavra "ponteiro" deve ser bem claro nesse contexto. Caso contrário, fique à vontade para me avisar e adicionarei uma frase à resposta.
AnoE 22/1018

ponteiros "combinam com o sistema de tipos" também em C ++ ;-). (Na verdade, os genéricos clássicos de Java são menos fortemente tipados que os do C ++.) A coleta de lixo é um recurso que o C ++ decidiu não exigir, mas é possível que uma implementação forneça um, e não tem nada a ver com a palavra que usamos para ponteiros.
Peter - Restabelece Monica

OK, @ PeterA.Schneider, eu realmente não acho que estamos chegando ao nível aqui. Eu removi o parágrafo em que mencionei os ponteiros, de qualquer forma não ajudou em nada a resposta.
AnoE

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 objetos, 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 você classtiver uma virtualfunção, o programa armazenará um ponteiro para essa função em uma tabela de funções virtual , que será inicializada para cada instância do classmomento em que for construída. Normalmente, isso significa uma tabela de função virtual para todas as instâncias de classe e cada instância segurando um ponteiro para a tabela apropriada. (Isso economiza tempo e memória porque a tabela será muito maior que um ponteiro.) Quando você chama essa virtualfunção por meio de um ponteiro ou referência, o programa procura o ponteiro de função na tabela virtual. (Se souber o tipo exato no momento da compilação, 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 ofstreamum contém um ponteiro para a ofstreamtabela virtual, cada um ifstreampara a ifstreamtabela 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 de objeto de classe!

Embora o padrão de linguagem não diga às pessoas que projetam compiladores como eles devem implementar o tempo de execução, é assim que você pode esperar dynamic_caste typeoftrabalhar.


"o padrão de linguagem não informa os codificadores", você provavelmente deve enfatizar que os "codificadores" em questão são as pessoas que escrevem gcc, clang, msvc, etc., não as pessoas que as usam para compilar seu C ++.
Caleth, 22/10/19

@ Caleth Boa sugestão!
Davislor
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.