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ção1 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 invocado2.
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 opcionais3. Para tornar a discussão mais concreta, vamos pegar nosso último exemplo do post anterior e modificá-lo conforme descrito acima:
Print(new S2());
Print(new S2(13));
void Print(S2 s) => System.Console.WriteLine(s.v);
struct S2
{
public required int v;
[System.Diagnostics.CodeAnalysis.SetsRequiredMembers]
public S2(int i = 42) => v = i;
}
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 inicializadas4 (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.
using System.Runtime.CompilerServices;
class Driver
{
static void Main()
{
for(int i = 0; i < 100_000; i++)
{
var foo = new Foo(); // Nenhum construtor invocado... nenhum aviso :(
Thread.Sleep(100);
foo.Use();
}
}
}
// A estrutura pode ser declarada em um assembly diferente também.
struct Foo
{
// é importante que o campo seja marcado como `readonly`
private static readonly bool _shouldValidate = Environment.GetEnvironmentVariable("VALIDATE_FOO") == "true";
private int _i;
private bool _isInitialized;
public Foo(int v = 1) { _i = v; _isInitialized = true; }
public void Use()
{
Verify();
System.Console.WriteLine(_i);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)] // É importante pedir que o JIT inline este método para que a otimização seja aplicada.
private readonly void Verify()
{
if (_shouldValidate)
{
if (!_isInitialized)
{
throw new Exception("O construtor de Foo não foi invocado; isso pode ser devido a uma declaração de array ou ...");
}
}
}
}
O código usa o campo _shouldValidate
para controlar se a verificação de inicialização4 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:
DOTNET_JitDisasm=Use dotnet run -c Release
o qual:
- em sistemas operacionais do tipo
unix
, define a variável de ambienteDOTNET_JitDisasm
comoUse
, 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
):
; Assembly listing for method Foo:Use():this (Tier1)
; Emitting BLENDED_CODE for X64 with AVX - Unix
; Tier1 code
; optimized code
; rsp based frame
; fully interruptible
; No PGO data
; 1 inlinees with PGO data; 0 single block inlinees; 0 inlinees without PGO data
G_M000_IG01: ;; offset=0x0000
G_M000_IG02: ;; offset=0x0000
mov edi, dword ptr [rdi]
G_M000_IG03: ;; offset=0x0002
tail.jmp [System.Console:WriteLine(int)]
; Total bytes of code 8
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:
DOTNET_JitDisasm=Use VALIDATE_FOO=true dotnet run -c Release
nesse caso, ele lançará uma exceção (provando que o uso de instâncias de struct não inicializadas é detectado)
ou
DOTNET_JitDisasm=Use dotnet run -c Debug
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!
-
aqui você pode encontrar alguns códigos de teste que usei enquanto explorava este tópico.↩
-
Depois de perceber isso, mudei o título do post :).↩
-
Este caso específico é problemático devido à expectativa de que o comportamento corresponderia ao comportamento para classes.↩
-
Nota de esclarecimento: Do ponto de vista da runtime, structs são garantidas de serem inicializados (zerando toda a struct) antes de serem usadas. Inicialização no contexto desta série significa que todos os campos/propriedades da struct foram atribuídos valores significativos, deixando a instância em um estado consistente.↩↩
No comments:
Post a Comment