C# Valor × Referência

Faça um teste: guarde um número numa variável, copie para outra e mude a cópia. A original não muda. Agora faça o mesmo com um objeto: mude a "cópia" e a original muda junto! Não é defeito. O C# tem dois tipos de tipo: os tipos de valor, em que a variável guarda o próprio dado, e os tipos de referência, em que a variável guarda o endereço de um objeto. Entender essa diferença evita uma família inteira de bugs.

Você vai aprender
  • O que é um tipo de valor e um tipo de referência, e quais tipos são de cada família.
  • O que acontece numa atribuição (b = a) em cada caso.
  • O que acontece quando você passa cada um para um método.
  • Por que os arrays e as listas se comportam como objetos, e por que o string parece um valor.
  • Quem pode ser null (o erro CS0037).
  • Boxing e unboxing: guardar um valor dentro de um object, e a InvalidCastException.

Todos os programas desta aula rodaram com dotnet run arquivo.cs no .NET 10 da máquina da Ana, na pasta ~/cs.


A ideia: a fotocópia e o endereço

A Ana tem uma receita de bolo e quer passá-la para a Bia. Ela pode fazer de dois jeitos:

  • Tirar uma fotocópia e dar a cópia. Se a Bia rabiscar a cópia dela, a receita da Ana continua limpa. Cada uma tem a sua. Isso é um tipo de valor.
  • Passar o endereço do blog onde a receita está publicada. Agora as duas olham a mesma receita. Se a Bia (que também tem a senha) editar a página, a Ana vê a mudança. Isso é um tipo de referência: a variável guarda o "endereço" do objeto, não o objeto.
Tipos de valor (a variável guarda o dado)Tipos de referência (a variável guarda o endereço)
int, long, double, decimal, bool, charToda class (as suas e as do .NET)
struct (como DateTime)string
enumArrays (int[]), List<T>, Dictionary
record struct, tuplasinterfaces, record, object, delegates

Uma regra fácil para o começo: class é referência; números, bool, char, struct e enum são valor. Dá para perguntar ao .NET:

Console.WriteLine(typeof(int).IsValueType);
Console.WriteLine(typeof(decimal).IsValueType);
Console.WriteLine(typeof(DateTime).IsValueType);
Console.WriteLine(typeof(string).IsValueType);
Console.WriteLine(typeof(int[]).IsValueType);
Console.WriteLine(typeof(List<int>).IsValueType);
$ dotnet run tipos.cs
True       « int
True       « decimal
True       « DateTime é uma struct
False      « string
False      « array
False      « List

Copiar um valor: cada um com o seu

int a = 5;
int b = a;
b = 10;
Console.WriteLine($"a = {a}, b = {b}");
int a = 5;
A variável a guarda o número 5 dentro dela.
int b = a;
O 5 é copiado para dentro de b. A partir daqui, a e b não têm nada a ver uma com a outra.
b = 10;
Muda só o b.
$ dotnet run valor.cs
a = 5, b = 10

Nada de surpresa: é o que você esperava desde a aula de Variáveis.


Copiar uma referência: os dois apontam para o mesmo objeto

Caderno c1 = new Caderno();
c1.Dono = "Ana";
Caderno c2 = c1;
c2.Dono = "Bia";
Console.WriteLine($"c1: {c1.Dono}, c2: {c2.Dono}");

class Caderno
{
    public string Dono { get; set; } = "";
}
Caderno c1 = new Caderno();
O new cria um objeto Caderno na memória. A variável c1 não guarda o caderno; guarda o endereço dele (a "referência").
Caderno c2 = c1;
Copia o endereço, não o caderno. Agora c1 e c2 são dois nomes para o mesmo objeto. Repare: não houve new, então não nasceu caderno nenhum.
c2.Dono = "Bia";
"Vá até o objeto para onde c2 aponta e troque o dono." É o mesmo objeto de c1.
$ dotnet run referencia.cs
c1: Bia, c2: Bia

Um desenho ajuda:

  variáveis              objetos na memória
  ┌──────────┐
  │ c1  ●────┼──────┐
  └──────────┘      ▼
  ┌──────────┐   ┌────────────────┐
  │ c2  ●────┼──▶│ Caderno        │
  └──────────┘   │ Dono = "Bia"   │
                 └────────────────┘
  ┌──────────┐
  │ a   5    │    « um int guarda o próprio número
  └──────────┘
"Pilha" e "heap"

Em livros e entrevistas você vai ouvir que "tipos de valor ficam na pilha (stack) e objetos ficam no heap". É uma boa imagem para começar: as variáveis locais ficam numa área rápida e organizada (a pilha), e os objetos criados com new ficam numa área maior, cuidada pelo coletor de lixo (o heap). Mas não é a regra inteira: um int que é campo de uma classe mora dentro do objeto, no heap. O que define a família é o comportamento na cópia, não o lugar.

Missão: a Ana quer que a Bia tenha um caderno dela. Troque Caderno c2 = c1; por um caderno novo (new Caderno()), para a saída ficar c1: Ana, c2: Bia.

Caderno c1 = new Caderno();
c1.Dono = "Ana";
Caderno c2 = c1;
c2.Dono = "Bia";
Console.WriteLine($"c1: {c1.Dono}, c2: {c2.Dono}");

class Caderno
{
    public string Dono { get; set; } = "";
}
Caderno c1 = new Caderno();
c1.Dono = "Ana";
Caderno c2 = new Caderno();
c2.Dono = "Bia";
Console.WriteLine($"c1: {c1.Dono}, c2: {c2.Dono}");

class Caderno
{
    public string Dono { get; set; } = "";
}

Passar para um método

Passar um argumento para um método é uma atribuição disfarçada: o parâmetro recebe uma cópia do que está na variável. Para um valor, é a cópia do dado. Para uma referência, é a cópia do endereço.

int pontos = 10;
DobrarNumero(pontos);
Console.WriteLine($"pontos = {pontos}");

Placar placar = new Placar { Pontos = 10 };
DobrarPlacar(placar);
Console.WriteLine($"placar.Pontos = {placar.Pontos}");

void DobrarNumero(int numero)
{
    numero = numero * 2;
}

void DobrarPlacar(Placar p)
{
    p.Pontos = p.Pontos * 2;
}

class Placar
{
    public int Pontos { get; set; }
}
$ dotnet run metodos.cs
pontos = 10           « o método dobrou a cópia
placar.Pontos = 20    « o método mexeu no mesmo objeto
DobrarNumero(pontos)
O parâmetro numero recebe uma cópia do 10. O método dobra a cópia, e ela some quando o método termina. A variável pontos nem fica sabendo.
DobrarPlacar(placar)
O parâmetro p recebe uma cópia do endereço. p e placar apontam para o mesmo objeto, então p.Pontos = … muda o placar de verdade.

Mas trocar o objeto inteiro não funciona

O método recebeu uma cópia do endereço. Se ele fizer o parâmetro apontar para outro objeto, a variável de fora continua apontando para o antigo:

Placar placar = new Placar { Pontos = 10 };
Zerar(placar);
Console.WriteLine($"placar.Pontos = {placar.Pontos}");

void Zerar(Placar p)
{
    p = new Placar();
    p.Pontos = 0;
}

class Placar
{
    public int Pontos { get; set; }
}
$ dotnet run trocar.cs
placar.Pontos = 10    « o Zerar mexeu num placar novo, que ninguém viu

O p = new Placar(); fez só o p apontar para um placar novo. Se você precisa que um método troque a variável de quem chamou, existe o ref (veja ref, out e in). Mas, quase sempre, o melhor é o método devolver o objeto novo com return.


Arrays e listas são referências

Um array é um objeto, mesmo que seja de int. Copiar a variável copia o endereço:

int[] notas = [7, 8, 9];
int[] copia = notas;
copia[0] = 10;
Console.WriteLine(string.Join(", ", notas));

int[] deVerdade = (int[])notas.Clone();
deVerdade[1] = 0;
Console.WriteLine(string.Join(", ", notas));
Console.WriteLine(string.Join(", ", deVerdade));
$ dotnet run arrays.cs
10, 8, 9     « mudar a "copia" mudou as notas
10, 8, 9     « o Clone criou um array novo: as notas ficaram
10, 0, 9
int[] copia = notas;
Não copia nada: copia é outro nome para o mesmo array.
(int[])notas.Clone()
O Clone() cria um array novo, com os mesmos elementos. Ele devolve object, por isso o (int[]) na frente (veja Conversão de Tipos). Com listas, use new List<int>(lista) ou lista.ToList().

E o string?

O string é um tipo de referência, mas parece um valor:

string s1 = "Ana";
string s2 = s1;
s2 = s2 + " Souza";
Console.WriteLine($"s1 = {s1}, s2 = {s2}");
$ dotnet run texto.cs
s1 = Ana, s2 = Ana Souza

O segredo é que um string é imutável: nenhum método muda o texto que já existe (veja Strings). O s2 + " Souza" cria um texto novo e faz o s2 apontar para ele, igual ao p = new Placar() de antes. O s1 continua apontando para o "Ana" antigo. Como ninguém consegue mudar um texto "por dentro", compartilhar a referência nunca causa surpresas. E o == do string compara o conteúdo, não o endereço.


Quem pode ser null

null quer dizer "não aponta para nada". Só faz sentido para referências: um endereço pode estar vazio, mas um int sempre guarda um número.

int idade = null;
$ dotnet run nulo.cs
/home/ana/cs/nulo.cs(1,13): error CS0037: Não é possível converter o valor nulo em 'int' porque ele não é um tipo de valor não anulável

Ocorreu uma falha no build. Corrija os erros de build e execute novamente.
Mais uma tradução confusa

"Porque ele não é um tipo de valor não anulável" parece dizer o contrário do que deveria. O original é Cannot convert null to 'int' because it is a non-nullable value type: "porque int é um tipo de valor que não aceita null". Para um valor que pode faltar, use int? (veja null e Tipos Anuláveis).


Boxing: um valor dentro de uma caixa

A variável object é uma referência: ela guarda um endereço. Então, como object caixa = 42; funciona, se o 42 é um valor? O C# faz um truque chamado boxing ("encaixotar"): cria um objeto no heap, coloca uma cópia do 42 dentro e guarda o endereço da caixa. O caminho de volta, tirar o valor da caixa, é o unboxing.

int n = 42;
object caixa = n;
n = 7;
Console.WriteLine($"n = {n}, caixa = {caixa}");

int deVolta = (int)caixa;
Console.WriteLine(deVolta + 1);
object caixa = n;
Boxing: a caixa recebe uma cópia do 42. Acontece sozinho, sem você escrever nada.
n = 7;
Muda o n. A caixa tem a sua própria cópia e continua com 42.
(int)caixa
Unboxing: tira o valor da caixa. Precisa do cast (int), porque o compilador só sabe que ali há "um object".
$ dotnet run boxing.cs
n = 7, caixa = 42
43

Sem o cast, o compilador recusa:

$ dotnet run semcast.cs
/home/ana/cs/semcast.cs(2,9): error CS0266: Não é possível converter implicitamente tipo "object" em "int". Existe uma conversão explícita (há uma conversão ausente?)

Ocorreu uma falha no build. Corrija os erros de build e execute novamente.

O unboxing tem de usar o tipo exato

Uma caixa com um int só abre como int. Nem um long, que cabe um int com folga, serve:

object caixa = 42;
long grande = (long)caixa;
Console.WriteLine(grande);
$ dotnet run unbox.cs
Unhandled exception. System.InvalidCastException: Unable to cast object of type 'System.Int32' to type 'System.Int64'.
   at Program.<Main>$(String[] args) in /home/ana/cs/unbox.cs:line 2
$ echo $?
134

Desta vez compilou, e o erro só apareceu rodando: uma exceção (em inglês, como toda exceção do .NET no Linux). "Não é possível converter um objeto do tipo Int32 (o int) para o tipo Int64 (o long)." O conserto é abrir a caixa com o tipo certo e, só depois, converter: long grande = (int)caixa;. Se você não sabe o que há na caixa, pergunte com is: if (caixa is int numero) { … }.

Por que evitar

Cada boxing cria um objeto novo na memória, e isso custa tempo. Num laço com milhões de voltas, faz diferença. Por isso o C# moderno usa generics: uma List<int> guarda os int direto, sem caixas, enquanto a antiga ArrayList (de antes dos generics) guardava tudo como object. Regra prática: use o tipo certo, não object, e o boxing quase nunca aparece.

Experimente: rode e veja a InvalidCastException e o código de saída 134. Depois, conserte de dois jeitos: com (int)caixa e com if (caixa is int numero). Teste também object caixa = 42L; (o L faz o número ser long). No editor do guia, a mensagem da exceção sai mais curta, Specified cast is not valid. ("a conversão indicada não é válida"); o tipo da exceção, a linha e o 134 são os mesmos do dotnet.

object caixa = 42;
long grande = (long)caixa;
Console.WriteLine(grande);

Quando dá errado

"Mudei a cópia e o original mudou junto"

O que aconteceu: não havia cópia. b = a com um objeto, um array ou uma lista só copia o endereço.

Como resolver: crie um objeto novo de propósito: new Caderno { Dono = c1.Dono }, (int[])notas.Clone(), new List<string>(lista). Para classes suas, um método Copiar() ajuda (é a missão final). Ou use um record, que tem o with para fazer cópias com mudanças.

"O método não mudou a minha variável"

O que aconteceu: o método recebeu uma cópia: do número (tipo de valor) ou do endereço (e trocou o parâmetro por outro objeto com new).

Como resolver: faça o método devolver o resultado (pontos = Dobrar(pontos);) ou, se for mesmo necessário, use ref.

CS0037: "Não é possível converter o valor nulo em 'int' …"
$ dotnet run nulo.cs
/home/ana/cs/nulo.cs(1,13): error CS0037: Não é possível converter o valor nulo em 'int' porque ele não é um tipo de valor não anulável

Como resolver: tipos de valor não aceitam null. Use int? se o valor pode faltar.

System.InvalidCastException: Unable to cast object of type 'System.Int32' to type 'System.Int64'.

O que aconteceu: um unboxing com o tipo errado.

Como resolver: abra a caixa com o tipo exato ((int)caixa) ou teste com is. Melhor ainda: evite guardar valores em object.


Referência

Tipo de valorTipo de referência
Exemplosint, double, decimal, bool, char, struct, enumclass, string, arrays, List, object, record
A variável guardaO próprio dadoO endereço de um objeto
b = a;Copia o dado: dois independentesCopia o endereço: o mesmo objeto
Passar para um métodoO método recebe uma cópia do dadoO método recebe uma cópia do endereço e pode mudar o objeto
Pode ser null?Não (CS0037); use int?Sim (string? com referências anuláveis)
Guardado em objectBoxing: vira uma cópia numa caixaSó o endereço é guardado
Perguntartypeof(T).IsValueType

Exercícios

1. Valor

int x = 3; int y = x; y = 8; Quanto vale x?

2. Referência

int[] a = [1, 2]; int[] b = a; b[0] = 9; Quanto vale a[0]?

3. Abrir a caixa

Complete para tirar o número de object caixa = 10;:

int n = ;

4. Qual destes é um tipo de referência?

5. void Zerar(Placar p) { p = new Placar(); } é chamado com Zerar(meuPlacar);. O que acontece com meuPlacar?

6. object caixa = 42; long g = (long)caixa; O que acontece?

Missão final: a Bia quis começar o carrinho dela a partir do da Ana e acrescentar um chá. Mas o método Copiar() devolve this (o próprio objeto), e o chá foi parar no carrinho da Ana também. Rode e veja. Conserte o Copiar(): ele deve criar um new Carrinho(), colocar nele os itens deste carrinho (novo.Itens.AddRange(Itens);) e devolver o novo. A saída deve ser Ana: Café e Bia: Café, Chá.

Carrinho daAna = new Carrinho();
daAna.Itens.Add("Café");

Carrinho daBia = daAna.Copiar();
daBia.Itens.Add("Chá");

Console.WriteLine($"Ana: {string.Join(", ", daAna.Itens)}");
Console.WriteLine($"Bia: {string.Join(", ", daBia.Itens)}");

class Carrinho
{
    public List<string> Itens { get; } = [];

    public Carrinho Copiar()
    {
        return this;
    }
}
Carrinho daAna = new Carrinho();
daAna.Itens.Add("Café");

Carrinho daBia = daAna.Copiar();
daBia.Itens.Add("Chá");

Console.WriteLine($"Ana: {string.Join(", ", daAna.Itens)}");
Console.WriteLine($"Bia: {string.Join(", ", daBia.Itens)}");

class Carrinho
{
    public List<string> Itens { get; } = [];

    public Carrinho Copiar()
    {
        Carrinho novo = new Carrinho();
        novo.Itens.AddRange(Itens);
        return novo;
    }
}

Pense no que aconteceria se o Copiar() fizesse novo.Itens = Itens; (se a propriedade tivesse set): os dois carrinhos teriam objetos diferentes, mas a mesma lista lá dentro, e o problema voltaria. Copiar o objeto de fora sem copiar as referências de dentro é chamado de cópia rasa; copiar tudo, até o fundo, é a cópia profunda.


Resumo

  • Tipo de valor (int, decimal, bool, struct, enum): a variável guarda o dado; b = a faz uma cópia independente.
  • Tipo de referência (class, string, arrays, listas): a variável guarda o endereço; b = a faz dois nomes para o mesmo objeto.
  • Métodos recebem cópias: do dado ou do endereço. Pelo endereço, o método pode mudar o objeto, mas não trocar a variável de quem chamou.
  • O string é referência, mas imutável, e por isso se comporta como valor. Tipos de valor não aceitam null (CS0037); use int?.
  • Boxing põe uma cópia de um valor numa caixa object; o unboxing exige o tipo exato (InvalidCastException). Prefira generics a object. Próxima aula: Structs.