DEV Community

Cover image for Performance: Structs e Cópias (Defensivas)
William Santos
William Santos

Posted on

Performance: Structs e Cópias (Defensivas)

Olá!

Este é mais um post da seção Performance e vamos falar sobre uma armadilha invisível no uso de structs em C#: as cópias defensivas.

Vamos lá!

Structs: Por quê?

Vamos recapitular: structs são uma escolha excelente para em cenários onde alocação de memória é uma questão crítica. Por existirem apenas na stack, tendo portanto uma existência efêmera e não sujeita à ação do Garbage Collector, seu uso afeta positivamente o desempenho de uma aplicação, efeito que pode ser constatado no caminho crítico da mesma via profile, ou via benchmarks em ambiente de desenvolvimento, quando avaliado diante de classes.

Mas nem tudo são flores!

Ainda que sejam uma alternativa ao uso de classes e tenham por principal característica a evitação da alocação de memória no heap, existe uma armadilha oculta: as cópias (defensivas).

Cópias: o fundamental

Quando uma struct é passada como parâmetro para um método, dada sua natureza de value type, a passagem acontece por valor, ou seja, uma cópia da struct em questão é gerada dentro do método, o que impede naturalmente que a struct original seja modificada, já que a cópia não é um ponteiro pra ela e encerra seu ciclo de vida ao final do método no qual foi recebida.

O Modificador in

Para permitir a passagem de structs como parâmetros por referência, surgiu o modificador in, o que impede que uma cópia da struct seja criada mas que, ao mesmo tempo, cria um risco: mudança implícita de estado.

Por ser passada por referência, qualquer alteração no estado interno da struct perdurará para além do método invocado, tornando a instância da struct vulnerável à mudanças indesejadas, ainda que o modificador in crie um contexto readonly que não permita a alteração explícita do estado da struct.

Cópias defensivas, uma (aparente) solução

Cópias defensivas são uma forma do compilador (Roslyn) evitar que sua struct sofra mudanças indesejadas quando passadas como parâmetros in, o que aumenta a segurança da aplicação contra efeitos colaterais. Mas, como bem sabemos, segurança sempre custará performance e é exatamente o que acontece neste caso.

Um pequeno exemplo

Aqui temos uma struct chamada Square que representa um quadrado. Um quadrado tem quatro lados iguais, portanto apenas o tamanho de um lado precisa ser conhecido. Vejamos:

public struct Square
{
    public double Side { get; init; }

    public double Area => Side * Side;
}
Enter fullscreen mode Exit fullscreen mode

Repare que esta struct é imutável, ou seja, suas propriedades não podem ser modificadas durante um dado processamento. O problema é que o compilador não sabe se há alguma mudança de estado acionada quando a propriedade Area é invocada e, por isso, cria uma cópia defensiva sempre que encontra uma invocação a este método.

Span<Square> squares = stackalloc Square[1_000];
for(int i = 0, i < squares.Lenght; i++)
{
    squares[i] = new Square { Side = i };
}

var panel = new Panel();
panel.Render(squares); //Renderiza os quadrados via propriedade `Area`.
Enter fullscreen mode Exit fullscreen mode

Adivinhe o que acontece aqui? Se você respondeu que mil cópias defensivas serão criadas a partir do array de Point, acertou na mosca!

Mas, William, mil cópias?!

Aqui está o pulo do gato: cada vez que uma propriedade é utilizada, o compilador cria uma cópia da sua struct toda pra garantir que aquela propriedade não afetará seu estado interno. Percebe o impacto potencial que estas operações geram sobre a performance?

Readonly ao resgate!

Felizmente existe uma forma de lidar com esse problema sem prejuízo da performance, o modificador readonly. Este modificador indica ao compilador que sua struct é imutável, o que garante que não haverá mudança de estado quando a struct for manipulada por um dado método.

O modificador readonly pode ser aplicado em duas dimensões: a struct inteira ou em certas propriedades. Vejamos:

public readonly struct Square
{
    public double Side { get; init; }

    public double Area => Side * Side;
}
Enter fullscreen mode Exit fullscreen mode

Com o modificador aplicado à struct toda, o compilador entender que não há a possibilidade de mudança de estado quando a instância tiver suas propriedades invocadas. Isso faz com que nenhuma cópia defensiva seja criada.

public struct Square
{
    public readonly double Side { get; init; }

    public double Area => Side * Side
}
Enter fullscreen mode Exit fullscreen mode

Com o modificador aplicado apenas a Side o compilador entenderá que a propriedade Area oferece o risco de mudança no estado interno, o que implicará uma cópia defensiva. Entretanto, o mesmo não acontecerá com a propriedade Side.

Performance: medindo o impacto

Abaixo temos uma demonstração do prejuízo à performance por força das cópias defensivas. Vejamos:

Benchmark

Aqui fica claro que, com structs grandes no caminho crítico da aplicação, sem o devido cuidado, as cópias defensivas oferecem um prejuízo perceptível, dado que o mesmo é mensurável em benchmark com dados fictícios.

Conclusão

Conhecer as características internas do compilador é muito útil para pensar sobre como desenhar suas structs e utilizá-las. Este é um caso onde seria simplesmente impossível considerar que suas structs são a causa do problema sem esse conhecimento.

Como sempre, o código do benchmark está disponível no GitHub para te permitir testar e modificar à vontade.

Gostou? Me deixe saber pelo indicadores. Caso tenha alguma dúvida, deixe um comentário ou me procure nas redes sociais. Será um prazer te responder.

Muito obrigado por ler até aqui, e até a próxima!

Top comments (0)