.NET: Brincando com ref structs
Pedro Jesus
Posted on April 3, 2024
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;
}
}
}
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 umValueType
, 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, umaList<int>
terá seus valores, que são inteiros (ValueType
s), 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
}
}
- Por padrão, todo
ValueType
é passado como cópia. Para garantir que a mudança de valor dentro deMyRefStruct
seja propagada para oflag
dentro da classeViewModel
, ele deve ser passado por referência. - Eu quero "setar" o valor da
flag
paratrue
quando for começar a lógica, por isso adiciono o segundo parâmetro no construtor. - O
using
garantirá que tudo entreusing var _ ...
e o último}
do métodoSomeCode_Jeito1
ficará dentro de umtry/finally
e chamará o métodoDispose
dentro deMyRefStruct
. Como você deve ter imaginado, dentro doDispose
mudaremos o valor deflag
parafalse
. No caso do métodoSomeCode_Jeito2
, tudo que estiver entre as{}
dousing
ficará dentro de umtry/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 {}
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 struct
s 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;
}
}
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 daflag
seráTrue
e fora voltará paraFalse
.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 modificadorasync
, dentro de uma lambda expression que capture essaref struct
em umaclosure
, 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
}
}
Deixo para você remover a linha
IsBusy = true;
e a abstrair dentro daref 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();
}
}
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);
Será reescrita em:
// Como o compilador reescreve
HoldAndChangeValueProperty holdAndChangeValueProperty = new HoldAndChangeValueProperty(new Action(<Run>b__4_0));
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 Action
s 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();
}
}
}
Ao final da execução, eis os resultados:
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
}
}
}
Com essa mudança, o código gerado pelo compilador será:
HoldAndChangeValueProperty holdAndChangeValueProperty = new HoldAndChangeValueProperty(updateIsBusy);
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:
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 struct
s. É 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á!
Posted on April 3, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.