Lire cet post en français.
Read this post in English
A história deste post é um pouco embaraçosa.
Durante o processo de definição dos tópicos que eu cobriria, aprendi sobre uma funcionalidade do C# 11 chamada required members
que, ingenuamente, pensei poderia ser usada para sinalizar esses cenários, então planejei adicionar um post mostrando como alcançar isso; no entanto, durante a investigação/redação do conteúdo percebi que esse não era um dos objetivos dessa funcionalidade e que havia vários casos extremos em que nenhum aviso seria emitido, mesmo que nenhum construtor fosse invocado.
A ideia principal era marcar todos os membros (campos/propriedades) que seriam inicializados nos construtores como required
e adicionar o atributo SetsRequiredMembers
a esses construtores, de forma que, nos casos em que nenhum construtor (decorado com SetRequiredMembersAttribute
) fosse invocado, o compilador emitiria um aviso/erro devido à não inicialização desses membros.
Essa técnica funciona relativamente bem se quisermos capturar um cenário muito problemático: a instanciação de tipos de valor com um construtor em que todos os seus parâmetros possuim valores opcionais. Para tornar a discussão mais concreta, vamos pegar nosso último exemplo do post anterior e modificá-lo conforme descrito acima:
Com essa mudança em vez de obter silenciosamente uma instância de S2
inicializada com zero (em oposição ao esperado 42 se o construtor fosse invocado), o compiladore emite o seguinte erro:
error CS9035: O membro obrigatório 'S2.v' deve ser definido no inicializador de objeto ou no construtor de atributo.
Não é perfeito, já que a mensagem provavelmente será muito confusa se alguém estiver (incorretamente, mas compreensivelmente) esperando que o construtor de S2
seja invocado, mas há outras limitações que tornam essa abordagem ainda menos viável:
- Incapacidade de detectar construtores não invocados (pelo menos) em expressões default e instanciações de arrays.
- Mesmo em cenários em que isso funcionaria, é impossível garantir que um construtor será invocado (por exemplo, se mudarmos o código na linha #1 para
new S2() { v = 5 }
, nenhum construtor será invocado, mas nenhum aviso/erro será emitido)
Uma alternativa mais eficaz (se você implantar sua aplicação como gerenciada em vez de compilá-la AOT) para detectar tais cenários é garantir explicitamente que instâncias de struct foram inicializadas (seja por um construtor ou por outros meios) antes de acessar seus membros; como o código está gerado em tempo de execução pelo JIT, é possível implementar isso de forma que os usuários possam controlar se tal verificação deve ser aplicada ou com muito pouco (ou nenhum) impacto em desempenho quando a verificação está desativada, como demonstrado abaixo.
O código usa o campo _shouldValidate
para controlar se a verificação de inicialização deve ser aplicada ou não (neste caso, mais especificamente, se o construtor foi executado). Observe que o mesmo é declarado como static readonly
; isso é muito importante pois assim o JIT sabe que, uma vez inicializada uma dada instância de struct, o valor do campo nunca mudará, podendo tratar _shouldValidate
como uma constante não gerando código para verificá-lo no if na linha #XX; além disso, caso esta constante seja avaliada como false, o JIT pode remover o if
(incluindo seu corpo) completamente (daí o custo quase zero mencionado anteriormente).
Você pode ver essa mágica do JIT em ação abrindo um terminal, criando uma aplicação de console com o código acima e executando:
o qual:
- em sistemas operacionais do tipo
unix
, define a variável de ambiente DOTNET_JitDisasm
como Use
, o que instrui o JIT a fazer o dump do código assembly gerado para o método de mesmo nome.
- compila a aplicação em modo release (
-c Release
), o que é um requisito para que a otimização seja aplicada.
- executa a aplicação.
Ao executar DOTNET_JitDisasm=Use dotnet run -c Release
, você deve ver zeros (0) e algum código assembly sendo impresso no terminal várias vezes; após algumas iterações, você deve ser capaz de identificar código assembly semelhante ao abaixo (certifique-se de verificar o que mesmo contém Tier1
em vez de Tier0
):
que basicamente chama System.Console.WriteLine(_i)
e retorna, sem nenhum traço da invocação do método Verify()
.
Você também pode brincar com este exemplo executando-o como:
nesse caso, ele lançará uma exceção (provando que o uso de instâncias de struct não inicializadas é detectado)
ou
não importando nesse caso quanto tempo a aplicação seja executada, o código assembly gerado para Use()
sempre chamará Verify()
(ou seja, a otimização não foi aplicada porque a aplicação foi compilada em modo debug)
Com essa abordagem, pode-se ter certeza de que nenhum código está usando instâncias não inicializadas
simplesmente executando o código com a variável de ambiente definida como true
e observando exceções.
Como sempre, todos os feedbacks são bem-vindos.
Divirta-se!