C#: Tipos de Valor e Tipos de Referência
O C# é uma linguagem de programação desenvolvida pela Microsoft, sendo a principal linguagem da plataforma .NET. A cada nova versão, a Microsoft introduz diversas melhorias que aprimoram a experiência do desenvolvedor e possibilitam construir software de forma mais robusta. Recentemente foi lançada a última versão do C# (v9).
Este post pretende iniciar uma série de publicações que abordarão o C#. Assim como um escritor de literatura precisa dominar o idioma para poder manejá-lo, e construir sentenças lógicas, também o desenvolvedor de software precisa explorar a linguagem que serve de instrumento para produzir software. Então, vamos praticar!
Common Type System
Um software escrito para a plataforma .NET é independente de linguagem. Isso significa duas coisas:
A plataforma conseguirá executar qualquer programa que possa ser compilado para a Linguagem Intermediária. F# e Visual Basic são exemplos de linguagens que compilam para a Linguagem Intermediária.
Você pode referenciar no seu projeto C#, bibliotecas escritas em F# (e vice-versa), mas compiladas para .NET, então essas linguagens precisam seguir determinadas convenções.
Os tipos de dados em C# são derivados do Common Type System (CTS), que é um sistema para especificar como as linguagens que compilam para a plataforma .NET devem trabalhar com os tipos.
O CTS define, por exemplo, várias categorias de tipos: classes, estruturas, enumerações, interfaces e delegados. Ele também define modificadores de acesso, como deve ser definida herança e sobrecarga de operadores.
Tipos de dados
Em C#, quando você cria uma variável e atribui a ela um valor, o armazenamento desse valor pode acontecer de maneiras diferentes dependendo do tipo. Isso acontece porque o CTS define tipos de valor e tipos de referência.
Tipos de Valor
Ao atribuir um valor a uma variável que seja do tipo de valor, você está armazenando uma instância desse tipo na memória. Ao atribuir essa variável a uma outra variável desse mesmo tipo, acontece uma cópia do valor. Por exemplo:
|
|
Ao fazer a atribuição do valor 20
à variável x
, é alocado um espaço na memória para armazenamento do valor 20
. Ao atribuir a variável x
à variável y
, um novo espaço de memória é alocado, para armazenar uma cópia do valor 20
. Ou seja, há dois espaços de memória separados para armazenamento desse valor.
Em C#, os tipos de valor são derivados de System.ValueType
, como os tipos numéricos integrais int
e byte
; os tipos numéricos de ponto flutuante como float
e double
; os tipos bool
, char
, struct
, enum
e tuplas.
Tipos de Referência
Diferentemente das variáveis de tipo de valor, ao iniciar uma variável do tipo de referência além de ser alocado um espaço na memória para armazenar o conteúdo atribuído, a variável iniciada é armazenada em um outro espaço de memória, onde existe uma referência para a primeira posição de memória do conteúdo. Isso significa que ao atribuir uma variável do tipo de referência a outra, apenas o valor da referência é copiado. Por exemplo:
|
|
Ao atribuir o objeto resultante da expressão new Car("Gol")
à variável car
, é alocado um espaço na memória para armazenar todos os valores que o objeto car
necessita. Além disso, um novo espaço na memória é alocado para armazenar o endereço de memória (referência) do objeto armazenado. Assim, ao atribuir à variável car2
o conteúdo da variável car
, somente a referência do objeto armazenado é copiada. Isso significa na prática que essas duas variáveis apontam para o mesmo local na memória.
Observe agora a seguinte situação:
|
|
Veja que a propriedade Name
foi alterada a partir da variável car2
. Se car2
aponta para a mesma posição de memória que car
, ao imprimir o conteúdo dessa propriedade através das duas variáveis, os valores retornados devem ser os mesmos, exibindo a alteração do valor da variável Name
.
Como as variáveis do tipo referência armazenam apenas endereços de memória, ao nular a variável car2
, nenhum impacto ocorreria em car
.
|
|
O mesmo ocorre em uma operação de inicialização de um novo objeto Car
sendo atribuído a variável car2
. Novos espaços de memória são alocados para armazenar o conteúdo desse objeto e car2
passa a conter a referência desse novo espaço de memória alocado.
|
|
Em C#, as classes, interfaces e delegates são tipos de referência. Variáveis declaradas como sendo do tipo string
e dynamic
também são tipos de referência.
Indo um pouco mais abaixo…
Todo esse comportamento que existe nos tipos de valor e tipos de referência está relacionado com a forma que o sistema operacional disponibiliza a um processo em execução o espaço de alocação de memória. Dois desses espaços são as abstrações stack e heap.
Stack
A área de memória stack (ou pilha) é uma área muito menor que a heap e funciona como uma estrutura de dados LIFO (last in first out). Toda vez que uma função é chamada, um bloco de memória é reservado na stack para armazenamento das variáveis locais da função. Quando a função retorna, essa área é desalocada. Como essa área é pequena, dependendo da quantidade de espaço requerido para uma função, pode ocorrer um erro chamado de stack overflow, indicando que a pilha está cheia. Devido ao tamanho e a forma de navegação, recuperar um dado da stack é muito rápido. Além disso, os espaços de memória da stack não são fragmentados.
Heap
A área de memória heap é destinada a alocação dinâmica de variáveis. Seu tamanho varia conforme o uso. Ela não é acessada diretamente como a stack. Ao contrário, o acesso a valores armazenados nela depende justamente da stack, onde existe uma referência para o dado contido na área de memória heap. Quando já não há nenhuma variável na stack apontando para posições de memória na heap, em C# existe o garbage collector, que é responsável por desalocar essas posições de memória. Recuperar um valor armazenado na heap pode não ser tão rápido quanto na stack devido ao tamanho e a fragmentação.
Áreas de memória vs Tipos de Dados
Dadas as características de cada uma das áreas de memória, você já deve estar imaginando onde cada um dos tipos de dados são armazenados. Em C#, todo tipo de referência é armazenado na área de memória heap. Já os tipos de valor em geral são armazenados na stack, com exceção de duas situações: (1) quando um tipo de valor é declarado como membro de uma classe, ele é armazenado no heap, junto com o restante da classe; (2) quando um tipo de valor é declarado como membro de uma struct
, ele é armazenado onde a struct
for armazenada (na stack se for membro de uma função local, ou na heap, se membro de uma classe).
Tipos anuláveis
Devido a forma como tipos de valor são armazenados não é possível atribuir null
a um tipo de valor. Mas podem haver situações em que um valor desse tipo é desejado. A versão 2 do C# introduziu o conceito de tipo de valor anulável. Um tipo de valor anulável T?
permite que qualquer variável do tipo de valor T possa ter um valor nulo. Todo tipo de valor anulável é derivado de System.Nullable<T>
. Por exemplo:
|
|
A todo tipo de referência pode ser atribuído o valor null
. E isso pode ser um problema. Quantos NullReferenceException
você já viu acontecer? Muitos, não é verdade? Para tentar resolver isso, o C# 8 introduziu a funcionalidade de tipos de referência anuláveis, que vem desabilitada por padrão. Ao habilitar essa funcionalidade, todos os tipos de referência que forem declarados sem a sintaxe T?
precisam ser inicializados com não nulos. Ao ferir a regra, durante a análise estática do código, o compilador produzirá um warning
. Por exemplo:
|
|
Com os tipos de referência anuláveis habilitado, para que a variável name
possa ser anulável, ela deve ser declarada como string? name
.
Passagem de Parâmetros
Em C#, a passagem de parâmetros para um método pode ser feita por valor ou por referência.
Passagem por Valor
Quando um parâmetro é passado para um método por valor, então uma cópia desse parâmetro é feita na memória. As alterações no parâmetro que ocorrem dentro do método não impactam os dados originais armazenados na variável de argumento. Por exemplo:
|
|
O valor da variável x
do método Main
é copiado para a variável x
do escopo do método DoubleIt
. Dessa forma, a variável x
do método Main
não sofre nenhuma modificação, pois tratam-se de variáveis diferentes.
Por padrão, em C# os argumentos sempre são passados por valor para um método. Quando um tipo de referência é passado para um método, é feita a cópia da sua referência.
Passagem por Referência
Para passar um parâmetro por referência deve-se utilizar os modificadores ref
ou out
.
O modificador ref
pode ser declarado junto ao parâmetro na declaração do método para passar um tipo de valor por referência. Por exemplo:
|
|
Quando ref
é utilizado com um tipo de referência, em vez de copiar a referência que a variável armazena para a variável do argumento, é passada a referência da variável que contém a referência do objeto. Dessa forma, é possível que dentro do método a variável passada possa apontar para outra referência. Por exemplo:
|
|
O modificador out
é utilizado de forma similar a ref
, mas obriga ao método que o utiliza a inicializar a variável, caso ela seja passada sem inicializar. Por exemplo:
|
|
A variável number
é passada para o método int.TryParse
por referência, uma vez que é utilizado o modificador out
. Ela pode ser passada sem inicializar, mas o método int.TryParse
terá que obrigatoriamente inicializá-la antes de retornar.
Conversão de Tipos
Um tipo de valor pode ser convertido em um tipo de referência e vice-versa. Essas operações são dispendiosas e podem afetar a performance da aplicação.
Boxing
Quando um tipo de valor é convertido para o tipo object
, esse processo é chamado de conversão boxing
. A conversão boxing de um tipo de valor armazena uma instância de objeto no heap
e copia o valor no novo objeto.
|
|
Unboxing
A conversão unboxing
é o oposto: a conversão de um tipo de referência para um tipo de valor. A conversão unboxing de um tipo de referência para um tipo de valor armazena o valor na stack
. Tentar realizar a conversão unboxing de uma referência para um tipo de valor incompatível causa uma InvalidCastException
.
|
|
A figura abaixo mostra o que acontece na heap e na stack quando os processos de conversão boxing e unboxing ocorrem.
Referências
MICROSOFT. Common Type System. Disponível em: https://docs.microsoft.com/pt-br/dotnet/standard/base-types/common-type-system
MICROSOFT. Tipos (Referência de Programação em C#). Disponível em: https://docs.microsoft.com/pt-br/dotnet/csharp/programming-guide/types/
MICROSOFT. Tipos de Referência (Referência em C#). Disponível em: https://docs.microsoft.com/pt-br/dotnet/csharp/language-reference/keywords/reference-types
MICROSOFT. Tipos de Valor (Referência em C#). Disponível em: https://docs.microsoft.com/pt-br/dotnet/csharp/language-reference/builtin-types/value-types