C#: Tipos de Valor e Tipos de Referência

Conteúdos

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!

Um software escrito para a plataforma .NET é independente de linguagem. Isso significa duas coisas:

  1. 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.

  2. 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.

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.

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:

1
2
int x = 20;
int y = x;

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.

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:

1
2
Car car = new Car("Gol");
Car car2 = car;

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:

1
2
3
4
5
6
7
Car car = new Car("Gol");
Car car2 = car;

car2.Name = "Uno";

Console.WriteLine(car.Name); // Uno
Console.WriteLine(car2.Name) // Uno

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.

1
2
3
4
5
6
7
Car car = new Car("Gol");
Car car2 = car;

car2 = null;

Console.WriteLine(car.Name); // Uno, provando que não houve impacto na variável car.
Console.WriteLine(car2.Name) // NullReferenceException, provando que car2 não aponta para nenhum lugar da memória

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.

1
2
3
4
5
6
7
Car car = new Car("Gol");
Car car2 = car;

car2 = new Car("Uno");

Console.WriteLine(car.Name); // Gol, provando que não houve impacto na variável car.
Console.WriteLine(car2.Name) // Uno, provando que car2 aponta para uma nova posição de memória.

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.

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.

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.

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.

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).

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:

1
2
char? letter = null;
letter = 'M';

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:

1
2
string name = null; // Warning! Uma variável declarada como não-nula está recebendo nulo.
var person = new Person(name); // Warning! Uma variável nula está sendo passada como parâmetro

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.

Em C#, a passagem de parâmetros para um método pode ser feita por valor ou por referência.

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
void DoubleIt(int x) 
{
  x =* 2;
  Console.WriteLine($"Valor de x dentro de DoubleIt: {x}");
}

static void Main()
{
  int x = 10;

  Console.WriteLine($"Valor de x antes de chamar DoubleIt: {x}");
  DoubleIt(x);
  Console.WriteLine($"Valor de x depois de chamar DoubleIt: {x}");
}

/* Saída:
    Valor de x antes de chamar DoubleIt: 10
    Valor de x dentro de DoubleIt: 20
    Valor de x depois de chamar DoubleIt: 10
*/

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.

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
static void DoubleIt(ref int x) 
{
  x =* 2;
  Console.WriteLine($"Valor de x dentro de DoubleIt: {x}");
}

static void Main()
{
  int x = 10;

  Console.WriteLine($"Valor de x antes de chamar DoubleIt: {x}");
  DoubleIt(ref x); // Passando por referência
  Console.WriteLine($"Valor de x depois de chamar DoubleIt: {x}");
}

/* Saída:
    Valor de x antes de chamar DoubleIt: 10
    Valor de x dentro de DoubleIt: 20
    Valor de x depois de chamar DoubleIt: 20
*/

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
static void Change(ref Person person)
{
  person.Name = "Maria";
  person = new Person("Joana");

  Console.WriteLine($"Valor de Name dentro de Change: {person.Name}");
}

static void Main()
{
  var person = new Person("Leticia");

  Console.WriteLine($"Valor de Name antes de chamar Change: {person.Name}");
  Change(person);
  Console.WriteLine($"Valor de Name depois de chamar Change: {person.Name}");
}

/* Saída:
    Valor de Name antes de chamar Change: Leticia
    Valor de Name dentro de Change: Joana
    Valor de Name depois de chamar Change: Joana
*/

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:

1
2
3
string numberAsString = "10";
int number;
int.TryParse(numberAsString, out number);

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.

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.

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.

1
2
int x = 123;
object o = x; // Essa operação faz a conversão boxing

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.

1
2
3
int x = 123;
object o = x; // Essa operação faz a conversão boxing
int y = (int) o; // Essa operação faz a conversão unboxing

A figura abaixo mostra o que acontece na heap e na stack quando os processos de conversão boxing e unboxing ocorrem.

/pt-br/posts/c-sharp-tipos-de-valor-e-referencia/boxing-unboxing.jpg
Esquema mostrando as conversões boxing e unboxing