Conteúdos

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:

  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.

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:

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.

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:

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.

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:

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.

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:

 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.

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:

 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.

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.

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

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.

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

Referências