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 é?
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 é?
Respostas:
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 Foo
e 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_info
objeto. 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.
void*
).
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.
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:
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:
void*
para orange*
quando há uma maçã na outra extremidade do ponteiro,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.
void*
coage a foo*
, as promoções aritméticas habituais, union
tipo trocadilhos, NULL
contra 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 é.
void*
não é convertido implicitamente em foo*
e o union
punking de tipo não é suportado (possui UB).
Uma variável possui várias propriedades fundamentais em um idioma como C:
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.
useT1(&unionArray[i].member1); useT2(&unionArray[j].member2); useT1(&unionArray[i].member1);
, clang e gcc tendem a supor que o ponteiro unionArray[j].member2
não pode acessar, unionArray[i].member1
mesmo que ambos sejam derivados do mesmo unionArray[]
.
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:
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).
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.
char *ptr = 0x123
em 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.
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ê class
tiver uma virtual
funçã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 class
momento 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 virtual
funçã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 ofstream
um contém um ponteiro para a ofstream
tabela virtual, cada um ifstream
para a ifstream
tabela 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_cast
e typeof
trabalhar.