Boxing e unboxing no C#: o que é e como evitar

Boxing e unboxing são as conversões que o C# faz quando um tipo de valor precisa se passar por um objeto e, depois, voltar a ser o tipo original. Embora pareçam detalhes de prova de certificação, essas conversões afetam o consumo de memória, o trabalho do garbage collector e até o comportamento do seu código.
Neste artigo, você vai ver na prática quando o boxing acontece, por que ele muitas vezes passa despercebido e como os generics resolvem o problema. Todos os exemplos usam o .NET 10, a versão LTS atual, mas os conceitos valem para qualquer versão da plataforma.
O que é boxing e unboxing no C#
Em termos simples, boxing é a conversão de um tipo de valor para object ou para uma interface que esse tipo implementa. Já o unboxing faz o caminho inverso e extrai o valor de dentro do objeto.
Segundo a documentação oficial do C# no Microsoft Learn, o boxing é implícito e o unboxing é explícito. Em outras palavras, o compilador embrulha o valor sem pedir licença, mas exige que você declare a intenção de desembrulhar.
Tipos de valor e tipos de referência
Para entender boxing e unboxing, primeiro precisamos separar os dois grandes grupos de tipos do C#. Tipos de valor, como int, double, bool e qualquer struct, guardam o dado diretamente na variável.
Tipos de referência, como classes, interfaces, arrays e string, funcionam de outro jeito. A variável guarda apenas um endereço, enquanto o objeto em si vive no heap gerenciado.
Por isso, quando você copia um int, o runtime copia o número inteiro. Por outro lado, quando você copia uma variável de uma classe, o runtime copia somente a referência, e as duas variáveis passam a apontar para o mesmo objeto.
Boxing implícito e unboxing explícito
Observe o trecho abaixo, que reproduz o exemplo clássico de boxing e unboxing.
int a = 5;
// Boxing: o valor de "a" vai para uma caixa no heap
object b = a;
// Unboxing: o valor sai da caixa e volta a ser um int
int c = (int)b;
Console.WriteLine(b); // 5
Console.WriteLine(c); // 5
Na segunda linha, o CLR cria um novo objeto no heap, copia o valor 5 para dentro dele e coloca na variável b apenas a referência para essa caixa. Dessa forma, a e b passam a ocupar lugares diferentes da memória.
Na terceira linha, o runtime confere se a caixa realmente contém um int e, então, copia o valor de volta. Se o tipo não bater, você recebe uma InvalidCastException em tempo de execução.
Exemplo prático de boxing e unboxing
O primeiro exemplo parece inofensivo porque o Console.WriteLine imprime 5 nas duas variáveis. No entanto, o comportamento muda assim que tentamos fazer contas, conversões ou comparações com o valor que está dentro da caixa.
A seguir, vamos explorar três situações em que boxing e unboxing costumam surpreender quem está começando.
Somando um object com um int
Imagine que você queira somar b e a. Embora as duas variáveis contenham o número 5, o código abaixo nem compila.
int a = 5;
object b = a;
// Erro de compilação: o operador + não existe entre object e int
// Console.WriteLine(b + a);
// Correto: primeiro fazemos o unboxing
Console.WriteLine((int)b + a); // 10
Isso acontece porque, para o compilador, b não é um inteiro, e sim um object. Portanto, você precisa fazer o unboxing com (int)b antes de usar o valor em uma operação aritmética.
Unboxing e conversão em duas etapas
Agora vamos dividir o valor por 2. Com um int comum, basta converter para double para não perder a parte decimal.
int a = 5;
object b = a;
Console.WriteLine(a / 2); // 2
Console.WriteLine((double)a / 2); // 2.5
// Compila, mas lança InvalidCastException em tempo de execução
// Console.WriteLine((double)b / 2);
// Correto: unboxing para int e, depois, conversão para double
Console.WriteLine((double)(int)b / 2); // 2.5
A linha comentada compila sem nenhum aviso, mas quebra ao executar. Isso acontece porque o unboxing só funciona para o tipo exato que entrou na caixa, e a caixa guarda um Int32, não um Double.
Na prática, você sempre precisa de duas etapas. Primeiro, tira o valor da caixa com o tipo original e, em seguida, converte o resultado para o tipo desejado.
Comparando dois valores boxed
O terceiro cenário envolve igualdade. Veja o que acontece quando comparamos duas caixas que guardam o mesmo número.
object b = 5;
object c = 5;
Console.WriteLine(b == c); // False
Console.WriteLine(b.Equals(c)); // True
Console.WriteLine((int)b == (int)c); // True
O resultado False assusta à primeira vista, mas faz sentido. Como b e c são duas caixas diferentes no heap, o operador == entre dois object compara as referências, e não os valores.
Para comparar o conteúdo, você pode chamar Equals, que o Int32 sobrescreve, ou fazer o unboxing dos dois lados. De fato, esse detalhe sobre boxing e unboxing aparece com frequência em entrevistas técnicas.
Structs, interfaces e boxing escondido
Até aqui, o boxing apareceu de forma bem visível, porque escrevemos object no código. Contudo, existe um caso muito mais sutil, que envolve structs e interfaces.
Considere a interface e o tipo abaixo.
public interface ICounter
{
int Count { get; set; }
void IncrementCount();
}
public class Counter : ICounter
{
public int Count { get; set; }
public void IncrementCount() => Count++;
}
Agora vamos criar um contador, atribuí-lo a uma variável do tipo da interface e incrementar apenas essa segunda variável.
Counter counter1 = new() { Count = 3 };
ICounter counter2 = counter1;
counter2.IncrementCount();
counter2.IncrementCount();
Console.WriteLine($"Counter 1: {counter1.Count}"); // 5
Console.WriteLine($"Counter 2: {counter2.Count}"); // 5
Com Counter declarado como classe, as duas variáveis mostram 5. Afinal, ambas apontam para o mesmo objeto no heap, e a interface só muda o contrato pelo qual enxergamos esse objeto.
Agora troque apenas a palavra class por struct e rode o mesmo código.
public struct Counter : ICounter
{
public int Count { get; set; }
public void IncrementCount() => Count++;
}
// Saída:
// Counter 1: 3
// Counter 2: 5
O resultado muda para 3 e 5, sem nenhuma outra alteração no código. Isso acontece porque, quando você atribui uma struct a uma variável de interface, o runtime faz boxing e cria uma cópia no heap.
Assim, counter2 aponta para essa cópia, e os incrementos não alcançam counter1. A própria especificação do C# destaca que boxing e unboxing de structs sempre copiam o valor, ao contrário do que ocorre com classes.
Portanto, trocar class por struct para "ganhar performance" sem entender essa diferença pode introduzir bugs difíceis de rastrear.
O custo de boxing e unboxing em memória
Os exemplos anteriores mostram efeitos no comportamento do código. No entanto, o impacto mais comum de boxing e unboxing aparece no consumo de memória e na pressão sobre o garbage collector.
Cada boxing aloca um novo objeto no heap. Além disso, o unboxing exige uma verificação de tipo a cada leitura, o que também consome processamento.
Medindo alocações na prática
Para medir quanto custa o boxing, podemos usar o método GC.GetAllocatedBytesForCurrentThread. Ele informa quantos bytes a thread atual já alocou, então basta ler o valor antes e depois de um trecho de código.
using System.Collections;
const int ListCount = 1_000_000;
long bytesBefore;
long totalBytes;
// Lista genérica de int
bytesBefore = GC.GetAllocatedBytesForCurrentThread();
List<int> list1 = new(ListCount);
for (int i = 0; i < ListCount; i++)
{
list1.Add(i);
}
totalBytes = GC.GetAllocatedBytesForCurrentThread() - bytesBefore;
Console.WriteLine($"List<int>: {totalBytes,15:N0} bytes allocated");
// ArrayList não genérico
bytesBefore = GC.GetAllocatedBytesForCurrentThread();
ArrayList list2 = new(ListCount);
for (int i = 0; i < ListCount; i++)
{
list2.Add(i);
}
totalBytes = GC.GetAllocatedBytesForCurrentThread() - bytesBefore;
Console.WriteLine($"ArrayList: {totalBytes,15:N0} bytes allocated");
Repare no separador _ em 1_000_000. O compilador ignora o underline, mas ele deixa o número muito mais fácil de ler, o que reduz erros de digitação em constantes grandes.
Lista genérica contra ArrayList
Em um processo de 64 bits, a saída fica próxima destes números.
List<int>: 4,000,056 bytes allocated
ArrayList: 32,000,056 bytes allocated
A List<int> aloca cerca de 4 MB, porque o array interno guarda um milhão de inteiros de 4 bytes cada. Depois disso, adicionar os números não custa nenhuma alocação extra, já que o espaço foi reservado no construtor.
Já o ArrayList aloca cerca de 32 MB, ou seja, oito vezes mais. Primeiro, o array interno guarda referências de 8 bytes em vez de inteiros de 4 bytes e, além disso, cada Add faz o boxing de um int em um objeto de 24 bytes no heap.
Em outras palavras, temos um milhão de caixas individuais, e não uma caixa grande com vários números dentro. Por fim, para ler esses valores, você ainda paga o unboxing de cada item.
Com cinco itens, a diferença seria irrelevante. Contudo, em uma API que atende milhares de requisições por segundo, esse padrão enche o heap, dispara coletas de lixo com frequência e deixa a aplicação lenta.
Como generics evitam boxing e unboxing
A diferença entre as duas listas tem um nome: generics. Quando você escreve List<int>, o runtime gera uma versão da lista especializada para int, e o array interno passa a ser um int[].
Assim, não há conversão para object em nenhum momento, e portanto não existe boxing e unboxing ao adicionar ou ler os itens. Da mesma forma, uma List<double> usa um double[] internamente, sempre com o tipo correto.
// Fortemente tipada: sem boxing
List<double> list3 = [1.5, 2.5, 3.5];
double first = list3[0];
// Sem tipo: cada item vira object
ArrayList legacyList = new() { 1.5, 2.5, 3.5 };
double legacyFirst = (double)legacyList[0]!; // unboxing obrigatório
Esse foi um dos principais motivos para a introdução dos generics no C# 2.0. Além do ganho de performance, eles trazem segurança de tipo, porque o compilador impede que você coloque uma string em uma List<int>.
Os generics também ajudam em métodos próprios. Quando você usa uma restrição como where T : IComparable<T>, o compilador chama o método da interface diretamente no tipo de valor, sem boxing.
public static T Max<T>(T left, T right) where T : IComparable<T>
=> left.CompareTo(right) >= 0 ? left : right;
int biggest = Max(10, 20); // nenhuma alocação no heap
Se você quer se aprofundar nesses fundamentos, os cursos de C# do balta.io cobrem tipos, coleções e generics em detalhes.
Outros cenários que causam boxing e unboxing
O ArrayList é o exemplo mais famoso, mas boxing e unboxing aparecem em outros pontos do dia a dia. Por isso, vale conhecer os casos mais comuns.
- Coleções não genéricas como
ArrayList,HashtableeQueuedo namespaceSystem.Collections. - Métodos com parâmetros
objectouparams object[], como sobrecargas antigas destring.Format. - Atribuição de uma struct a uma variável de interface, como no exemplo do
ICounter. - Chamada de
Equals(object)ouGetHashCodeem structs que não sobrescrevem esses métodos. - Chamada de
GetTypeem um tipo de valor, que sempre faz boxing antes de retornar o tipo.
Para structs que você mesmo cria, implemente IEquatable<T>. Dessa forma, coleções como Dictionary<TKey, TValue> e HashSet<T> comparam os valores sem embrulhá-los.
public readonly struct Point(int x, int y) : IEquatable<Point>
{
public int X { get; } = x;
public int Y { get; } = y;
// Comparação tipada: sem boxing
public bool Equals(Point other) => X == other.X && Y == other.Y;
public override bool Equals(object? obj) => obj is Point other && Equals(other);
public override int GetHashCode() => HashCode.Combine(X, Y);
}
Uma boa notícia é que o C# moderno elimina parte desses casos automaticamente. Desde o C# 10, por exemplo, a interpolação de strings usa handlers genéricos, e $"{number}" não faz mais boxing do inteiro.
Outro caso que merece atenção envolve tipos anuláveis. Quando você faz boxing de um int? sem valor, o runtime devolve uma referência nula em vez de criar uma caixa vazia.
Por outro lado, se o int? tiver valor, a caixa recebe o int subjacente, e não o Nullable<int>. Por isso, você pode fazer o unboxing desse objeto tanto para int quanto para int?.
Como identificar boxing no seu código
Como o compilador faz o boxing sem avisar, nem sempre ele salta aos olhos durante uma revisão de código. Felizmente, existem algumas formas práticas de encontrá-lo.
A primeira é inspecionar o IL gerado pelo compilador. Ferramentas como o ILSpy e o próprio painel de IL de alguns editores mostram a instrução box exatamente onde a conversão ocorre, e a instrução unbox.any onde o valor sai da caixa.
IL_0001: ldloc.0
IL_0002: box [System.Runtime]System.Int32
IL_0007: stloc.1
Nesse trecho, a instrução box aparece logo depois de carregar a variável local. Ou seja, qualquer linha do seu código que gere esse opcode está alocando um objeto novo no heap.
A segunda forma é medir. Com o BenchmarkDotNet, basta adicionar o atributo [MemoryDiagnoser] à classe de benchmark para ver quantos bytes cada método aloca por execução.
using BenchmarkDotNet.Attributes;
using System.Collections;
[MemoryDiagnoser]
public class CollectionBenchmarks
{
private const int ItemCount = 1_000;
[Benchmark(Baseline = true)]
public int GenericList()
{
List<int> items = new(ItemCount);
for (int i = 0; i < ItemCount; i++) items.Add(i);
return items.Count;
}
[Benchmark]
public int NonGenericList()
{
ArrayList items = new(ItemCount);
for (int i = 0; i < ItemCount; i++) items.Add(i);
return items.Count;
}
}
Nesse benchmark, a coluna Allocated deixa claro o peso de boxing e unboxing no método NonGenericList. Em seguida, você pode comparar as duas versões e decidir se a otimização compensa.
Por fim, em produção, ferramentas como o dotnet-counters mostram a taxa de alocação e a frequência de coletas do garbage collector. Se esses números sobem sem motivo aparente, vale procurar boxing em loops e caminhos muito executados.
Quando boxing e unboxing ainda fazem sentido
Nada disso significa que você deve caçar cada boxing do sistema. Em código que roda poucas vezes, como configuração de inicialização ou telas administrativas, o custo de boxing e unboxing costuma ser desprezível.
Também existem APIs que trabalham com object por natureza, como reflection, serialização genérica e bibliotecas legadas. Nesses casos, o boxing faz parte do contrato, e tentar evitá-lo pode complicar o código sem ganho real.
Além disso, otimizações prematuras costumam trocar legibilidade por ganhos que ninguém percebe. Por isso, meça primeiro com um benchmark ou um profiler e só depois altere o código.
Uma regra simples ajuda bastante na prática. Se o trecho roda dentro de um loop, em uma rota muito acessada ou manipula coleções com milhares de itens, vale revisar com atenção o uso de object e de coleções não genéricas.
O ponto central é saber quando ele acontece. Assim, você consegue decidir de forma consciente onde vale otimizar, principalmente em loops, hot paths e coleções grandes.
Conclusão
Boxing e unboxing são mecanismos fundamentais do sistema de tipos do C#, porque permitem que qualquer valor seja tratado como object. No entanto, cada caixa custa uma alocação no heap, e cada retirada custa uma verificação de tipo.
Ao longo do artigo, vimos que o unboxing exige o tipo exato, que comparar caixas com == compara referências e que structs atribuídas a interfaces viram cópias independentes. Além disso, medimos que um ArrayList aloca cerca de oito vezes mais memória que uma List<int> com os mesmos dados.
A solução, na maioria dos casos, passa pelos generics e por APIs fortemente tipadas. Afinal, código que funciona nem sempre funciona bem, e entender o que acontece por baixo dos panos é o que separa uma aplicação que escala de uma que trava sob carga.
FAQ
O que é boxing no C#?
Boxing é a conversão implícita de um tipo de valor para object ou para uma interface. O runtime cria um objeto no heap e copia o valor para dentro dele.
O que é unboxing no C#?
Unboxing é a conversão explícita de um object de volta para o tipo de valor original. O runtime verifica o tipo da caixa e copia o valor para a variável.
Por que o unboxing lança InvalidCastException?
O unboxing só aceita o tipo exato que entrou na caixa. Para obter outro tipo, faça o unboxing para o tipo original e converta depois, como em (double)(int)value.
Boxing acontece com structs?
Sim, sempre que uma struct é convertida para object ou para uma interface que ela implementa. Nesse caso, a variável da interface aponta para uma cópia independente.
Como evitar boxing e unboxing?
Prefira coleções genéricas como List<T> e Dictionary<TKey, TValue>, use restrições genéricas e implemente IEquatable<T> nas suas structs.