Tipos por Valor e Tipos por Referência

Tipos por Valor e Tipos por Referência

A divisão mais importante do sistema de tipos do C#: tipos por valor guardam o conteúdo, tipos por referência guardam um endereço. A aula mostra a consequência decisiva na cópia e na passagem para métodos, a diferença entre igualdade de conteúdo e de identidade, e a exceção da string.
Linguagem C#

• • 12 min de leitura

A aula anterior nos deu o mapa da memória: valores simples vivem diretamente na variável (na pilha), objetos vivem no heap, e variáveis de objeto guardam apenas uma referência — um endereço — que aponta para eles. Agora colhemos o fruto desse conhecimento, enfrentando de frente um comportamento que confunde praticamente todo iniciante e que é fonte de bugs sutis: copiar um objeto não é o mesmo que copiar um número. Formalizaremos a distinção entre tipos por valor e tipos por referência, e você entenderá, na raiz, por que atribuir uma variável de objeto a outra faz as duas apontarem para a mesma coisa, por que passar um objeto a um método permite alterá-lo, e por que dois objetos "iguais" às vezes não são considerados o mesmo. É uma das aulas mais esclarecedoras do curso — e ela só faz sentido porque você já sabe onde cada coisa vive.

As duas famílias de tipos

Todo tipo em C# pertence a uma de duas famílias, que correspondem exatamente ao que vimos sobre a memória. Os tipos por valor guardam o dado diretamente na variável — quando você usa a variável, você tem o valor em si. São os números (int, double, decimal), o bool, o char. Os tipos por referência guardam na variável apenas uma referência (um endereço) que aponta para o dado real, que vive no heap. São os objetos que você cria a partir de classes (Pessoa, Produto), as listas (List), e — com uma ressalva que veremos — as strings. A distinção, que na aula anterior era sobre onde os dados vivem, agora ganha um nome e consequências práticas: tipos por valor contêm seu dado; tipos por referência apontam para ele.

A consequência decisiva: o que acontece ao copiar

Toda a importância dessa distinção se revela quando copiamos uma variável — o que acontece ao atribuir uma variável a outra. Comecemos com um tipo por valor, um número:

int a = 10;
int b = a;    // copia o VALOR 10 para b; agora a e b têm cada um o seu 10
b = 99;       // altero b...
Console.WriteLine(a);   // 10 — 'a' NÃO mudou
Console.WriteLine(b);   // 99

Com o número, a atribuição b = a copiou o valor 10 de fato: a e b ficaram com cópias independentes. Alterar b não tocou em a, pois cada um tem o seu próprio 10, em sua própria gaveta. Isso é intuitivo e esperado. Agora observe o mesmo padrão com um tipo por referência, um objeto:

class Caixa { public int Valor { get; set; } }

Caixa x = new Caixa();
x.Valor = 10;
Caixa y = x;       // copia a REFERÊNCIA, não o objeto! x e y apontam para o MESMO objeto
y.Valor = 99;      // altero através de y...
Console.WriteLine(x.Valor);   // 99 — 'x' MUDOU também!
Console.WriteLine(y.Valor);   // 99

Aqui está a surpresa. A atribuição y = x não copiou o objeto — copiou apenas a referência, o endereço. Lembrando a analogia da aula anterior: x e y viraram dois papéis com o endereço da mesma casa. Quando alteramos y.Valor = 99, fomos até aquela casa (o único objeto) e mudamos algo dentro dela — e como x aponta para a mesma casa, ele "vê" a mudança. Não há dois objetos; há um só objeto e duas variáveis apontando para ele. Este é o comportamento que a aula anterior antecipou, agora confirmado: com tipos por referência, copiar a variável copia o endereço, não o objeto, e as duas variáveis passam a compartilhar a mesma coisa.

Esta é, muito provavelmente, a maior fonte de confusão do iniciante com objetos, e vale gravar a lição: quando você atribui um objeto a outra variável, não cria uma cópia do objeto — cria outra referência ao mesmo objeto. Se você quiser de fato uma cópia independente, precisa criar um novo objeto e copiar os dados, o que é uma operação deliberada, não o que a simples atribuição faz.

O mesmo comportamento ao passar para métodos

Essa distinção reaparece — e causa as mesmas surpresas — quando passamos valores a métodos, pois passar um argumento é, em essência, uma atribuição ao parâmetro. Um tipo por valor chega ao método como cópia; alterá-lo lá dentro não afeta o original. Um tipo por referência chega como cópia do endereço; alterar o objeto apontado afeta o original:

static void MudarNumero(int n) { n = 99; }
static void MudarObjeto(Caixa c) { c.Valor = 99; }

int numero = 10;
MudarNumero(numero);
Console.WriteLine(numero);   // 10 — inalterado (o número foi copiado)

Caixa caixa = new Caixa { Valor = 10 };
MudarObjeto(caixa);
Console.WriteLine(caixa.Valor);   // 99 — ALTERADO! (o método recebeu a referência)

O método MudarNumero recebeu uma cópia do número, e mexer nela não tocou no original. Já MudarObjeto recebeu uma cópia da referência — que aponta para o mesmo objeto — e, ao alterar c.Valor, alterou o objeto real, visível de fora. Este comportamento é imensamente útil (permite que métodos modifiquem objetos que recebem), mas precisa ser compreendido, ou vira fonte de bugs: um iniciante que passa um objeto a um método esperando que o original fique intacto se surpreenderá. A regra: métodos podem alterar os objetos que recebem (tipos por referência), mas não os números que recebem (tipos por valor), porque uns compartilham o dado real e os outros recebem cópias.

Igualdade: mesmo conteúdo ou mesma coisa?

A distinção afeta ainda o significado de "igual". Para tipos por valor, comparar com == verifica se os conteúdos são iguais: 5 == 5 é verdadeiro porque os valores coincidem. Para tipos por referência, == verifica, por padrão, se as duas variáveis apontam para o mesmo objeto — a mesma casa —, e não se os objetos têm o mesmo conteúdo:

Caixa c1 = new Caixa { Valor = 10 };
Caixa c2 = new Caixa { Valor = 10 };   // outro objeto, mesmo conteúdo
Console.WriteLine(c1 == c2);   // False — são objetos DIFERENTES (casas diferentes)

Caixa c3 = c1;                 // c3 aponta para o mesmo objeto que c1
Console.WriteLine(c1 == c3);   // True — é o MESMO objeto

c1 e c2 têm o mesmo conteúdo (ambos com Valor 10), mas são objetos distintos, em endereços diferentes — duas casas idênticas por dentro, mas casas separadas —, então == os considera diferentes. Já c3, que recebeu a referência de c1, aponta para a mesma casa, então c1 == c3 é verdadeiro. Para tipos por referência, portanto, == pergunta "é o mesmo objeto?", não "têm o mesmo conteúdo?". (É possível ensinar uma classe a comparar por conteúdo, e existem tipos especiais que já o fazem, mas isso fica para mais adiante; por ora, saiba que o comportamento padrão de == para objetos é comparar identidade, não conteúdo.)

A exceção conveniente: a string

Vale uma nota sobre a string, que temos usado desde o começo. Tecnicamente, a string é um tipo por referência — mas ela foi projetada com um comportamento especial: o == entre strings compara conteúdo, não identidade. Por isso "oi" == "oi" é verdadeiro, como você esperaria intuitivamente, mesmo sendo a string um tipo por referência. Essa é uma conveniência deliberada, porque comparar textos por conteúdo é quase sempre o que queremos. Mencione a string como o "caso especial que confirma a regra": ela é a exceção projetada para se comportar de forma intuitiva, enquanto os objetos comuns que você cria seguem a regra geral de comparar por identidade. Não se preocupe em decorar todas as sutilezas; guarde que a string "se comporta bem" na comparação, e que seus próprios objetos, por padrão, comparam por identidade.

A divisão mais importante do sistema de tipos do C# ficou clara. Tipos por valor guardam o próprio conteúdo; tipos por referência guardam um endereço. E a consequência decisiva aparece na cópia: copiar um valor produz um duplicado independente, enquanto copiar uma referência produz outro apontador para o mesmo objeto — alterar por um dos nomes altera o que o outro enxerga.

O mesmo vale ao passar argumentos para métodos, e é por isso que um método pode modificar um objeto recebido e a mudança permanecer visível fora dele. A string é a exceção conveniente: tecnicamente um tipo por referência, ela é imutável e, por isso, comporta-se na prática como se fosse um tipo por valor.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Reproduza os dois primeiros exemplos da aula (o do int e o da Caixa) e confirme as saídas. Depois, com suas palavras, explique por que alterar b não afetou a no caso do número, mas alterar y.Valor afetou x.Valor no caso do objeto.

Ver resposta

✓ Resposta: Alterar b não afetou a no caso do número porque int é um tipo por valor: a atribuição b = a copiou o próprio valor 10, dando a a e b gavetas independentes, cada uma com o seu 10; mexer numa não toca na outra. Alterar y.Valor afetou x.Valor no caso do objeto porque Caixa é um tipo por referência: a atribuição y = x copiou apenas a referência (o endereço), de modo que x e y apontam para o mesmo objeto no heap; alterar o objeto através de y altera a única coisa que ambos referenciam, e x "vê" a mudança.

Exercício 2

Crie uma classe Ponto com properties X e Y. Crie um objeto p1, atribua-o a p2 (Ponto p2 = p1;), e altere p2.X. Verifique que p1.X também mudou, e explique por quê, usando a ideia de referência.

Ver resposta

✓ Resposta: Exemplo:

class Ponto { public int X { get; set; } public int Y { get; set; } }
Ponto p1 = new Ponto { X = 1, Y = 2 };
Ponto p2 = p1;     // p2 recebe a referência de p1
p2.X = 99;
Console.WriteLine(p1.X);   // 99 — mudou também

p1.X mudou porque Ponto p2 = p1 não criou um novo ponto; copiou a referência, fazendo p1 e p2 apontarem para o mesmo objeto. Alterar p2.X alterou o único objeto existente, que p1 também referencia — logo, p1.X reflete a mudança.

Exercício 3

Escreva um método void Zerar(Caixa c) que faça c.Valor = 0, e um método void Zerar(int n) que faça n = 0. Chame ambos e mostre que o objeto foi alterado mas o número não. Explique a diferença com base em tipos por valor e por referência.

Ver resposta

✓ Resposta: Exemplo:

void Zerar(Caixa c) { c.Valor = 0; }
void Zerar(int n) { n = 0; }

Caixa caixa = new Caixa { Valor = 50 };
Zerar(caixa);
Console.WriteLine(caixa.Valor);   // 0 — objeto alterado

int numero = 50;
Zerar(numero);
Console.WriteLine(numero);        // 50 — número inalterado

A diferença: Zerar(Caixa c) recebeu uma cópia da referência, que aponta para o mesmo objeto; alterar c.Valor alterou o objeto real, visível fora do método. Zerar(int n) recebeu uma cópia do valor; alterar n mexeu só na cópia local, deixando o original intacto. Objetos (tipos por referência) são alteráveis pelos métodos que os recebem; números (tipos por valor) não.

Exercício 4

Crie dois objetos Caixa com o mesmo Valor e compare-os com ==. O resultado é True ou False? Depois, atribua um deles a uma terceira variável e compare essa terceira com o original. Explique os dois resultados em termos de "mesmo objeto" versus "mesmo conteúdo".

Ver resposta

✓ Resposta: Exemplo:

Caixa a = new Caixa { Valor = 10 };
Caixa b = new Caixa { Valor = 10 };
Console.WriteLine(a == b);   // False
Caixa c = a;
Console.WriteLine(a == c);   // True

a == b é False porque, embora tenham o mesmo conteúdo (Valor 10), são objetos distintos no heap, em endereços diferentes; o == padrão para objetos compara identidade (se é o mesmo objeto), não conteúdo. a == c é True porque c = a fez c apontar para o mesmo objeto que a — é a mesma coisa, então a comparação de identidade dá verdadeiro. Em resumo: a e b são "casas idênticas mas separadas"; a e c são "a mesma casa".

Exercício 5

Sem código: um colega criou um objeto configuracao, fez var backup = configuracao; achando que estava fazendo uma cópia de segurança, e depois alterou configuracao. Ele reclama que o "backup" também mudou. Explique o que realmente aconteceu e o que ele precisaria fazer para obter uma cópia de verdade.

Ver resposta

✓ Resposta: O que realmente aconteceu é que var backup = configuracao; não criou uma cópia do objeto — como configuracao é um tipo por referência, a atribuição copiou apenas a referência, fazendo backup e configuracao apontarem para o mesmo objeto. Por isso, quando ele alterou configuracao, o "backup" pareceu mudar também: não havia dois objetos, apenas duas variáveis referindo-se a um só. Para obter uma cópia de verdade, ele precisaria criar um novo objeto e copiar, um a um, os dados do original para ele (ou usar um mecanismo de clonagem projetado para isso) — uma operação deliberada de duplicação, e não a simples atribuição, que apenas compartilha a referência.

Comentários

Mais em Linguagem C#

Conversando com o Usuário: Entrada e Saída
Conversando com o Usuário: Entrada e Saída

Como fazer o programa conversar com quem o usa: exibir informação com…

Boas Práticas: Os Hábitos do Bom Programador
Boas Práticas: Os Hábitos do Bom Programador

Os hábitos que separam código que funciona de código que se sustenta: nomes…

Operadores e Expressões: Fazendo Contas e Combinando Valores
Operadores e Expressões: Fazendo Contas e Combinando Valores

Como fazer contas e combinar valores em C#: os operadores aritméticos, o resto…