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

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, Hashtable e Queue do namespace System.Collections.
  • Métodos com parâmetros object ou params object[], como sobrecargas antigas de string.Format.
  • Atribuição de uma struct a uma variável de interface, como no exemplo do ICounter.
  • Chamada de Equals(object) ou GetHashCode em structs que não sobrescrevem esses métodos.
  • Chamada de GetType em 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.