.NET: Brincando com ref structs

jesus

Pedro Jesus

Posted on April 3, 2024

.NET: Brincando com ref structs

Dias atrás, resolvi brincar com ref structs, fazendo alguns experimentos e pensando em como reduzir o boilerplate de usar try/finally para trocar o valor de uma variável Booleana. Foi um processo divertido. Como vejo pouco conteúdo sobre ref structs, em pt-BR, resolvi compartilhá-lo aqui. Acredito que vai ficar um pouco grande, pois vou falar sobre vários aspectos que fiz e alguns problemas e soluções. Irei separar em subtítulos para facilitar a leitura.

O código apresentado aqui é bem simples. Caso queira usá-lo em produção, sugiro adicionar alguns testes unitários para garantir o perfeito funcionamento.
E O MAIS IMPORTATE, conte-me como foi!

1. O Problema

Diversas vezes, me vi escrevendo códigos que precisam de um valor Booleano para saber se a lógica deve ser executada ou não. Usualmente, algo assim:

class ViewModel
{
    private bool flag;

    void SomeCode()
    {
        if (flag)
            return;

        flag = true;
        try
        {
            // Código omitido...
        }
        finally
        {
            flag = false;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Imagine que teria de utilizar essa flag em vários lugares, métodos dentro dessa classe ou até esse mesmo padrão em outros lugares... Não gostaria de ter que ficar reescrevendo essa estrutura do try/finally, pois ela é bem extensa. Quero uma solução que eliminará esse boilerplate e garantirá que não impactará a performance da aplicação. Se você já está mais familiarizado com performance, sabe que o ideal é evitar que existam alocações na memória heap. E para garantir isso vou precisar de usar ref struct (não confundir com struct).

Mesmo que struct seja um ValueType, isso não é garantia que ela estará na memória stack. Com isso, aquela afirmação de que "ValueType vive na stack e ReferenceType vive na heap" é um equivoco enorme!
Existem muito mais nuances, eis aqui uma discussão do time que escreve a linguagem e o runtime sobre esse tema, vale a pena a leitura.
Apenas um exemplo: para provar meu ponto, uma List<int> terá seus valores, que são inteiros (ValueTypes), alocados na memória heap.

2. Começando a solucionar o problema

Então, como dito anteriormente, quero garantir que a solução viva na memória stack, tendo o mínimo de impacto na performance da aplicação - tenhamos isso em mente. Antes de começar a implementar uma API, EU gosto de pensar em como gostaria de consumir esse código. Com isso definido, fica mais fácil trabalhar na implementação. Claro que pode haver mudanças durante o processo por conta de alguma limitação. Então, abaixo está o código que eu desejo escrever:

class ViewModel
{
    private bool flag;

    void SomeCode_Jeito1()
    {
        if (flag)
            return;

        using var _ = new MyRefStruct(ref flag, valueToSet: true);

        // Resto da lógica
    }

    void SomeCode_Jeito2()
    {
        if (flag)
            return;

        using(new MyRefStruct(ref flag, valueToSet: true))
        {
            // Resto da lógica
        }
        // Outra lógica
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Por padrão, todo ValueType é passado como cópia. Para garantir que a mudança de valor dentro de MyRefStruct seja propagada para o flag dentro da classe ViewModel, ele deve ser passado por referência.
  2. Eu quero "setar" o valor da flag para true quando for começar a lógica, por isso adiciono o segundo parâmetro no construtor.
  3. O using garantirá que tudo entre using var _ ... e o último } do método SomeCode_Jeito1 ficará dentro de um try/finally e chamará o método Dispose dentro de MyRefStruct. Como você deve ter imaginado, dentro do Dispose mudaremos o valor de flag para false. No caso do método SomeCode_Jeito2, tudo que estiver entre as {} do using ficará dentro de um try/finally.

Aproveitando, adivinhe onde a variável flag ficará alocada? Não é na memória stack 😉.

Essa abordagem me deixa bem contente. Afinal, troquei todo aquele boilerplate por apenas 1 linha. Com esses itens em mente, criaremos nossa ref struct.

3. Criando a ref struct

Você já deve saber que para usar o using eu preciso de implementar a interface IDisposable. É uma interface bem simples, que tem apenas um método na sua definição void Dispose(). Então, teríamos algo desse tipo:

ref struct HoldAndChangeValue : IDisposable {}
Enter fullscreen mode Exit fullscreen mode

Mas, isso não compilará! Por quê?! Bom, quando uma struct implementa uma interface, ela pode ser alocada na memória heap, por meio do boxing. E como ref structs NÃO podem ser alocadas na heap, de jeito nenhum, o compilador não permite que ela implemente interfaces.

Sim, meu jovem Padawan, eu sabia dessa limitação, mas quis pregar uma peça em você. Consegui? Bom, se sim, agora, vou te ensinar algo bem bacana.

"Para nossa alegria", várias funcionalidades da linguagem C# são baseadas em duck type. Logo, eu só preciso ter o método public void Dispose(){} definido no meu tipo e, voilà, podemos usá-lo. Para os outros tipos (class, struct etc), é necessário implementar a interface IDisposable.
Algumas funcionalidades do C# utilizam o duck type , como: foreach, async/await etc. Inclusive, eis aqui um exemplo de como fazer com que isto: await 42;, seja válido.

Baseado nas informações dos parágrafos anteriores e em como queremos usar em código, nossa HoldAndChangeValue ficará:

ref struct HoldAndChangeValue
{
    ref bool value;

    public HoldAndChangeValue(ref bool flag, bool valueToSet)
    {
        value = ref flag;
        value = valueToSet;
    }

    public void Dispose()
    {
        value = !value;
    }
}
Enter fullscreen mode Exit fullscreen mode

E é isso! O código fica mais simples de escrever e o funcionamento é como esperado. Fique a vontade para verificar aqui.

No link acima, tente implementar a estrutura do método SomeCode_Jeito2. Você perceberá que dentro das chaves o valor da flag será True e fora voltará para False.

Acredito que vale a pena reforçar, que a ref struct possui diversas limitações de uso. Isso serve para garantir que ela não seja alocada na memória heap. Então, essa solução não funcionará em métodos que têm o modificador async, dentro de uma lambda expression que capture essa ref struct em uma closure, dentre outros cenários.

4. Expandindo nossa ref struct

A solução proposta no capítulo anterior resolve boa parte dos cenários. Porém, não lida com propriedades. Para quem usa o padrão MVVM, por exemplo, seria muito interessante ter suporte a propriedades. Acredito que já tenha visto a estrutura do IsBusy. Caso não conheça, é semelhante ao exemplo que usei. Contudo, em vez de usar um field utiliza-se uma propriedade.
O maior problema é que não se pode passar propriedades como referência. Isso se deve ao fato de que propriedades, no C#, são um sugar syntax para métodos (getter & setter).
É possível ver isso aqui. Dentro do IL (Intermediated Language), você verá que não há um valor para essa propriedade, como há para o field, mas métodos de get & set.

E qual a solução? Pelo que pesquisei e experimentei, isso só pode ser feito usando delegates. Nesse caso, usei uma Action. Só que Action, assim como delegates, são ReferenceTypes. Logo, são alocados por padrão, na memória heap... E agora?

Inclusive, se você souber outra maneira de copiar o comportamento do ref com propriedades, conte-me, estou muito interessado!

Agora, é só adicionar uma Action como parâmetro no construtor da ref struct e criar uma variável local para isso e vida que segue... Mas, espere aí... Como colocarei algo que é alocado na heap dentro de algo que não pode ser? Bom, se você conseguiu ler e entender a discussão que fiz referência no começo do artigo, sobre onde se alocam ValueType & ReferenceType, ficará mais claro.

Digo "conseguiu ler e entender", acima, pois toda a discussão está em inglês e eu sei que nem todo mundo tem proficiência nessa linguagem, em um país como o nosso é completamente aceitável esse cenário. Particularmente, EU digo que é muito importante praticar o inglês se quiser se tornar um(a) profissional melhor. De todo modo, acredito que as ferramentas que traduzem sites estejam bem melhores atualmente. Usem-nas sempre que possível. Mas, logo abaixo, eu explico melhor o "Eureka" desse caso em particular.

Continuando... Sim, nossa ref struct NÃO viverá na memória heap, mas ela pode referenciar objetos que viverão. Isso porque o tempo de vida desses objetos serão maiores do que o tempo de vida de nossa ref struct. Quando passarmos a Action para nossa ref struct, o que passaremos, na verdade, é o endereço de memória dessa Action, o qual fica na memória stack. Logo, ele pode viver tranquilamente na nossa ref struct. Para uma visualização gráfica do que estou falando, acesse aqui.

Com isso, veremos, primeiramente, como eu gostaria de consumir o código e, então, implementaremos a solução.

class ViewModel
{
    public bool IsBusy {get; set;} = false;
    public void Run()
    {
        if (IsBusy)
        return;

        IsBusy = true;
        using var _ = new HoldAndChangeValueProperty(() => IsBusy = !IsBusy);

        // Código a ser executado   
    }
}
Enter fullscreen mode Exit fullscreen mode

Deixo para você remover a linha IsBusy = true; e a abstrair dentro da ref struct.

A implementação, nesse caso, será bem simples também. Basta apenas passar a Action para a ref struct e a executar dentro do método Dispose.

ref struct HoldAndChangeValueProperty
{
    Action propAction;

    public HoldAndChangeValue(Action action)
    {
        propAction = action;
    }

    public void Dispose()
    {
        propAction();
    }
}
Enter fullscreen mode Exit fullscreen mode

Para ver o código em execução, basta acessar aqui.

Para dar suporte ao uso de propriedades, a solução, como um todo, tocará na memória heap, pois precisaremos de uma Action para fazê-lo funcionar corretamente. Alocando uma única Action é algo tranquilo, pois o tamanho será de apenas alguns bytes. O problema é se isso for um trecho de código muito executado, daí pode ser necessário verificar o impacto de performance. E é exatamente isso que vou falar no próximo capítulo.

5. Alocações implícitas (Implicit Allocations)

Basicamente, alocações implícitas são quando algo vai ser armazenado na memória heap, mas sem que isso seja mostrado diretamente. Por exemplo, var obj = new object();, essa chamada está alocando um novo objeto na memória heap. Isso é explicito, pois estamos instanciando esse objeto com o uso da keyword new.
Agora, object i = 10; é uma alocação implícita, por meio de boxing. Estou convertendo o inteiro 10 em um ReferenceType, que será armazenado na memória heap. Se quiser aprofundar no tema, recomendo este artigo do Stephen Toub.

No nosso caso, onde acontece essa alocação? Ela ocorre quando passamos a Action para a ref struct. Se observamos o que o compilador gera pelo código, você verá que o compilador reescreve boa parte do nosso código, mas a principal, nesse cenário, é esta linha:

// O que escrevemos
using var _ = new HoldAndChangeValueProperty(() => IsBusy = !IsBusy);
Enter fullscreen mode Exit fullscreen mode

Será reescrita em:

// Como o compilador reescreve
HoldAndChangeValueProperty holdAndChangeValueProperty = new HoldAndChangeValueProperty(new Action(<Run>b__4_0));
Enter fullscreen mode Exit fullscreen mode

Onde o <Run>b__4_0 é um método gerado pelo compilador. Olhando essa linha, é possível perceber que toda vez que esse método é executado, será alocada uma nova Action, que executará o mesmo método... Isso é bem ineficiente, afinal se o método é o mesmo por que não fazer um cache?

Você já deve ter ouvido/lido a seguinte frase "compiladores são burros". Se não ouviu, acredito que ouvirá/lerá em algum momento da jornada como pessoa desenvolvedora. Eu concordei com essa frase por muito tempo. Agora, eu penso de outra maneira. Eu diria que os compiladores são conservadores, eles preferem, por padrão, garantir o funcionamento correto em vez de otimizações.

Como, por padrão, compiladores preferem garantir estabilidade em vez de otimizações. Esse tipo de cenário é bem comum em vários pontos de qualquer linguagem.

5.1 Entendendo e solucionado o problema

Vamos criar um cenário em que esse trecho de código será executado 10 mil vezes. O que você acha que ocorrerá? Exato, serão alocadas 10 mil Actions para fazer exatamente a mesma coisa. Para comprovar, criei um código de teste. O método que será utilizado para analisar as alocações é este:

    public void Run()
    {
        for (var i = 0; i < 10_000; i++)
        {
            viewModel.Run();
            if (i is 5_000)
            {
                Console.ReadLine();
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

Ao final da execução, eis os resultados:

Imagem do resultado de alocações de System.Action na classe ViewModel.

Como podemos ver a classe ViewModel no método Run, alocou 10 mil Actions, como eu havia dito. Para mim, esse comportamento é inaceitável. Mas, ele é um problema que está fora do escopo da nossa ref struct, pois quem cria essas alocações é o compilador. Para resolver isso, devemos criar, manualmente, o cache dessa Action e usá-la. Refatorando o código da ViewModel, teremos:

class ViewModel
{
    public bool IsBusy { get; set; } = false;

    readonly Action updateIsBusy;

    public ViewModel()
    {
        updateIsBusy = () => IsBusy = !IsBusy;
    }

    public void Run()
    {
        using (new HoldAndChangeValue(updateIsBusy))
        {
            // Código a ser executado
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Com essa mudança, o código gerado pelo compilador será:

HoldAndChangeValueProperty holdAndChangeValueProperty = new HoldAndChangeValueProperty(updateIsBusy);
Enter fullscreen mode Exit fullscreen mode

Como podemos ver, não existe mais uma nova alocação toda vez que o método Run rodar. Para comprovar, utilizaremos o mesmo código de testes nessa nova versão da classe ViewModel. Os resultados podem ser vistos na imagem abaixo:

Imagem do resultado de alocações de System.Action na classe ViewModel.

Como pode ser observado, apenas uma Action foi instanciada e será utilizada em todas as chamadas do método ViewModel.Run.

6. Conclusão

Concluímos nossa aventura pela ref struct. Como você deve ter notado, existe bastante coisa para se entender além da ref struct por si só. Espero que as referências, aqui citadas, possam te ajudar nessa jornada e agregar ainda mais conhecimento.

Não tenha medo de utilizar ref structs. É um recurso novo na linguagem, bem nichado também, mas vale a pena se familiarizar com ele e outros recursos. Afinal, eles permitem que possamos escrever códigos performáticos sem precisar de usar unsafe.

Por favor, conte-me suas aventuras com ref struct. Estou muito interessado no que você criará!

💖 💪 🙅 🚩
jesus
Pedro Jesus

Posted on April 3, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

.NET: Brincando com ref structs
csharp .NET: Brincando com ref structs

April 3, 2024