Introdução
Existe um entendimento comum, mas que considero impreciso, sobre tipos valor e tipos referência em DotNet. A maioria dos artigos e entrevistas técnicas foca em uma distinção simplista sobre o que são tipos valor e tipos referência. Quem nunca leu ou ouvi a seguinte frase: "tipos valor são alocados na stack (pilha), tipos referência são alocados no heap". Embora esta explicação tenha base prática, ela não apresenta as diferenças fundamentais entre esses tipos e se concentra em detalhes de implementação ao invés de suas características essenciais.
Em 2009 Eric Lippert escreveu o artigo The Stack Is An Implementation Detail, Part One, que considero um divisor de águas e recomendo a leitura. O link está nas referências deste artigo.
Conceitos Fundamentais
Segundo o padrão ECMA 335 (link na referência), qualquer valor descrito por um tipo é uma instância desse tipo. Em resumo, um tipo valor é aquele onde a instância contém diretamente todos seus dados, sendo auto-contida. Já um tipo referência contém apenas uma referência (ponteiro) para seus dados, que estão armazenados em outro lugar. É justamente essa diferença básica que faz cada tipo funcionar de uma maneira própria.
Características e Comportamentos
O tempo de vida dos dados em tipos valor é exatamente igual ao tempo de vida da instância que os contém, enquanto em tipos referência o tempo de vida dos dados é independente da referência que os aponta, sendo determinado pelo número de referências ativas. Além disso, tipos valor são sempre copiados quando passados entre locais diferentes, com cada cópia sendo independente e não afetando o original. Tipos referência, por outro lado, são compartilhados por padrão através de referências, permitindo que múltiplas referências apontem para os mesmos dados.
Exemplificando: crie uma variável inteira (tipo valor) dentro de um método, aquele número existe apenas durante a execução do método. Já quando você cria um objeto Pedido (tipo referência), ele pode ser referenciado por várias variáveis simultaneamente, persistindo enquanto qualquer uma dessas referências existir.
Identidade e Igualdade
Outra importante diferença está na identidade: tipos valor não possuem identidade própria, sendo considerados iguais se seus dados são idênticos bit a bit. Tipos referência possuem identidade baseada em sua localização na memória, sendo considerados iguais apenas se apontam para o mesmo local da memória.
Implementação e Armazenamento
O DotNet possui diferentes locais lógicos para armazenamento de dados, incluindo:
- variáveis locais em métodos
- Alocadas durante a execução do método e liberadas automaticamente após seu término. São armazenadas na pilha quando possível, oferecendo acesso rápido e desalocação automática ao sair do escopo do método.
- argumentos de métodos
- Parâmetros recebidos pelo método, também armazenados na pilha. São copiados (para tipos valor) ou têm suas referências copiadas (para tipos referência) durante a chamada do método.
- campos de instância
- Membros de dados pertencentes a uma instância específica de uma classe/struct. Para classes, são alocados no heap junto com o objeto. Para structs, seguem a localização da estrutura que os contém.
- campos estáticos
- Dados compartilhados entre todas as instâncias de uma classe. São alocados em uma área especial do heap no carregamento do domínio de aplicação e persistem até seu descarregamento.
- pool de memória local
- Área de memória gerenciada dentro do escopo de um método para alocações dinâmicas locais. Implementada como extensão do frame de ativação na pilha, oferece alocação rápida para dados temporários.
- pilha de avaliação temporária
- Área usada pelo JIT para operações intermediárias durante a execução de expressões. Pode utilizar registradores da CPU ou memória temporária na pilha, otimizada para operações de curta duração.
A atual implementação utiliza diferentes estratégias de armazenamento dependendo do contexto. Para tipos valor, variáveis locais em métodos, argumentos de métodos e pool de memória local são normalmente armazenados na pilha. Campos de instância em tipos referência, campos estáticos e situações de boxing utilizam o heap. Durante operações temporárias na pilha de avaliação, os dados podem ser mantidos em registradores da CPU para maior eficiência.
Otimizações
A implementação de tipos referência é mais direta: embora a referência em si (um ponteiro) possa ser armazenada em qualquer lugar, os dados referenciados são normalmente armazenados no heap. Existem otimizações possíveis, como a análise de escape - uma técnica que permite alocar objetos na pilha quando se pode garantir que não "escapam" do escopo local. Esta técnica é implementada a partir do DotNet Core 3. Eu tenho um artigo no qual eu exploro essa técnica: Alocando classes na Stack. O DotNet 9 trouxe novidades neste item, mas isso é papo para outro artigo.
Performance e Desenvolvimento
Em termos de performance, é interessante notar que a alocação é similar entre pilha e heap em termos de custo, consistindo basicamente em um simples movimento de ponteiro em ambos os casos. A grande diferença está na desalocação, que é muito mais eficiente na pilha (requerendo apenas um ajuste de ponteiro) do que no heap (que necessita do processo de garbage collection). Ao desenvolver aplicações, a decisão entre usar tipo valor ou referência deve ser baseada principalmente na semântica desejada, nas características de identidade necessárias e nos padrões de compartilhamento esperados. Otimizações baseadas em localização de memória só devem ser consideradas com dados concretos de profiling, e transformar tipos referência em tipos valor apenas por questões de performance raramente é justificável.
Em parceria com o RSNUG (Rio Grande do Sul .NET Users Group), apresentei uma live sobre introdução a códigos de alta performance, demonstrando na prática como uma pilha funciona. Confira a gravação em: link do vídeo.
Conclusão
A distinção "pilha vs. heap", embora importante para entender aspectos de performance, é fundamentalmente um detalhe de implementação, não uma característica definidora dos tipos. O que realmente importa são as características semânticas: como os valores são copiados e compartilhados, como a identidade é tratada e como o tempo de vida é gerenciado. Entender estas características fundamentais permite tomar melhores decisões de design, independente dos detalhes de implementação subjacentes. Desenvolvedores que compreendem estas nuances podem criar código mais robusto e eficiente, focando nas características semânticas que realmente importam para sua aplicação, em vez de se preocupar excessivamente com detalhes de implementação que podem mudar entre diferentes versões ou implementações do runtime DotNet.
Top comments (0)