Jan 29, 2025

Les structures en C# sont amusantes - Partie 7/9: Le modificateur *required* du C # 11 ne sauvegardera pas votre emploi

Leia este post em Português.

Read this post in English

L'histoire de ce post est un peu embarrassante.

Pendant le processus de définition des sujets que j'allais couvrir, j'ai découvert une fonctionnalité de C# 11 appelée required members que j'ai naïvement pensé pouvoir utiliser pour signaler ces scénarios, donc j'ai prévu d'ajouter un post montrant comment y parvenir ; cependant, pendant l'investigation/rédaction1 du contenu, j'ai réalisé que ce n'était pas l'un des objectifs de cette fonctionnalité et qu'il y avait plusieurs cas particuliers dans lesquels aucun avertissement ne serait émis même si aucun constructeur ne serait invoqué2.

L'idée principale était de marquer tous les membres (champs/propriétés) qui seraient initialisés dans les constructeurs comme required et d'ajouter l'attribut SetsRequiredMembers à ces constructeurs de manière à ce que dans les cas où aucun constructeur (décoré avec SetRequiredMembersAttribute) ne serait invoqué, le compilateur émettrait un avertissement/erreur en raison de la non-initialisation de ces membres.

Cette technique fonctionne relativement bien si l'on veut détecter un scénario très problématique : l'instanciation de types valeur avec un constructeur dans lequel tous ses paramètres sont optionnels3. Pour rendre la discussion plus concrète, prenons notre dernier exemple du post précédent et modifions-le comme décrit ci-dessus :

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;
}

Avec ce changement en place, au lieu d'obtenir silencieusement une instance de S2 initialisée à zéro (contre la valeur attendue de 42 si le constructeur était invoqué), nous obtenons l'erreur suivante :

error CS9035: Required member 'S2.v' must be set in the object initializer or attribute constructor.

Pas parfait, étant donné que le message sera probablement très confus si l'on s'attend (à tort, mais compréhensible) à ce que le constructeur de S2 soit invoqué, mais il y a d'autres limitations qui rendent cette approche encore moins viable :

  • Incapacité à détecter les constructeurs non invoqués (au moins) dans les expressions default et les instanciations de tableaux.
  • Même dans les scénarios où cela fonctionnerait, il est impossible de garantir qu'un constructeur sera invoqué (par exemple, si nous changeons le code à la ligne #1 en new S2() { v = 5 }, aucun constructeur ne sera invoqué mais aucun avertissement/erreur ne sera émis non plus)

Une alternative plus efficace (si vous déployez votre application en tant qu'application managée plutôt que de la compiler AOT) pour détecter de tels scénarios consiste à vérifier explicitement que les instances de struct ont été initialisées4 (soit par un constructeur, soit par d'autres moyens) avant d'accéder à ses membres ; puisque le code est JITé, on peut implémenter cela de manière à ce que les utilisateurs puissent contrôler si la vérification doit être appliquée ou non et avoir très peu (voire aucun) impact sur les performances lorsque l'application est désactivée, comme démontré ci-dessous.

using System.Runtime.CompilerServices;

class Driver
{
    static void Main()
    {

        for(int i = 0; i < 100_000; i++)
        {
            var foo = new Foo(); // Aucun constructeur invoqué... aucun avertissement :(
            Thread.Sleep(100);
            foo.Use();
        }
    }

}

// Le struct peut être déclaré dans un autre assembly également. 
struct Foo
{
    // il est important que le champ soit marqué comme `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)] // Il est important de demander au JIT d'inliner cette méthode pour que l'optimisation soit appliquée.
    private readonly void Verify()
    {
        if (_shouldValidate)
        {
            if (!_isInitialized)
            {
                throw new Exception("Le constructeur de Foo n'a pas été invoqué ; cela peut être dû à une déclaration de tableau ou ...");
            }
        }
    }
}

Le code utilise le champ _shouldValidate pour contrôler si la vérification de l’initialisation4 doit être appliquée ou non (dans ce cas plus spécifiquement, si le constructeur a été exécuté). Notez que ce champ est déclaré comme static readonly. Cela est très important car avec cela en place, le JIT sait que, pour une instance de struct donnée, une fois initialisée, la valeur du champ ne changera jamais, donc il est libre de traiter _shouldValidate comme une constante et de ne pas générer de code pour le vérifier dans le if à la ligne #XX ; de plus, dans le cas où il est évalué à false, le JIT peut supprimer toute l'instruction if (d'où le surcoût proche de zéro mentionné précédemment).

Vous pouvez voir cette magie du JIT en action en ouvrant un terminal, en créant une application console avec le code ci-dessus et en exécutant :

DOTNET_JitDisasm=Use dotnet run -c Release

ce qui :

  1. sur les systèmes d'exploitation de type unix, définit la variable d'environnement DOTNET_JitDisasm à Use
  2. compile l'application en mode release (-c Release), ce qui est une exigence pour que l'optimisation soit appliquée.
  3. exécute l'application.
  4. demande au JIT de déverser le code assembleur JITé pour la méthode Use() en définissant la variable d'environnement DOTNET_JitDisasm en conséquence.

Lorsque vous exécutez cette ligne de commande, vous devriez voir des zéros (0) et du code assembleur ffiché dans le terminal plusieurs fois. Après quelques itérations, vous devriez être capable de repérer du code assembleur ressemblant à celui ci-dessous (assurez-vous de vérifier celui qui contient Tier1 au lieu 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

Ce qui consiste essentiellement à appeler System.Console.WriteLine(_i) et à retourner, sans aucune trace de l’invocation de la méthode Verify().

Vous pouvez également expérimenter cet exemple en l’exécutant comme suit :

DOTNET_JitDisasm=Use VALIDATE_FOO=true dotnet run -c Release

dans ce cas, il lancera une exception (prouvant que l'utilisation d'instances de struct non initialisées sont détectées)

ou

DOTNET_JitDisasm=Use dotnet run -c Debug

dans ce cas, peu importe combien de temps l'application s'exécute, le code assembleur généré pour Use() appellera toujours Verify() (c'est-à-dire que l'optimisation n'a pas été appliquée car l'application a été compilée en mode debug)

Avec cette approche, on peut être sûr qu'aucun code n'utilise d'instances non initialisées en exécutant simplement le code avec la variable d'environnement définie à true et en observant les exceptions.

Comme toujours, tous les commentaires sont les bienvenus.

Amusez-vous bien !


  1. ici vous pouvez trouver du code de test que j'ai utilisé en explorant ce sujet.

  2. Après avoir réalisé cela, j'ai changé le titre du post :).

  3. Ce cas particulier est problématique en raison de l'attente que le comportement corresponde au comportement pour les classes

  4. Note de clarification : Du point de vue du runtime, les structs sont garantis d'être initialisés (en mettant à zéro tout le struct) avant d'être utilisés. Initialisation dans le contexte de cette série de posts signifie que tous les champs/propriétés du struct ont été assignés à des valeurs significatives, laissant l'instance dans un état cohérent.

Structs em C# - diversão garantida - Parte 7/9: A funcionalidade *required members* do C# 11 não vai salvar seu emprego

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çã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:

  1. 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.
  2. compila a aplicação em modo release (-c Release), o que é um requisito para que a otimização seja aplicada.
  3. 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!


  1. aqui você pode encontrar alguns códigos de teste que usei enquanto explorava este tópico.

  2. Depois de perceber isso, mudei o título do post :).

  3. Este caso específico é problemático devido à expectativa de que o comportamento corresponderia ao comportamento para classes.

  4. 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.

Jan 2, 2025

Structs in C# are fun - Part 7/9: Required feature from C# 11 will not save your a** job.

Leia este post em Português.

Lire cet post en français.

The history of the current post is a little bit embarrassing.

During the process of defining the topics I'd cover I've learned about a C# 11 feature called required members which I naively though could be used to flag these scenarios so I planned to add a post showing how to achieve that; however during investigation/drafting1 the content, I've realized that that was not one of the goals of this feature and that there were multiple corner cases in which no warning would be emitted even though no constructor would be invoked2.

The main idea would be to to mark all members (field/properties) that would be initialized in the constructors as required and add the SetsRequiredMembers attribute to these constructors in such a way that in cases where no constructors (decorated with SetRequiredMembersAttribute) were to be invoked the compiler would emit a warning/error due to the non-initialization of such members.

This technique works relatively well if one wants to catch a very problematic scenario: instantiation of value types with a constructor in which all of its parameters are optional3. To make the discussion more concrete, lets take our last example from the previous post and modify it as described above:

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;
}

With this change in place, instead of silently getting an instance of S2 initialized with zero (as opposed to the expected 42 if the constructor were to be invoked), we get the following error:

error CS9035: Required member 'S2.v' must be set in the object initializer or attribute constructor.

Not perfect, given that the message will probably be very confusing if one is (incorrectly, but understandably) expecting S2 constructor to be invoked, but there are other limitations rendering this approach even less viable:

  • Inability to detect constructors not being invoked (at least) in default expression and array instantiations.
  • Even in scenarios in which this would work it is impossible to guarantee that a ctor will be invoked (for instance, if we change the code at line #1 to new S2() { v = 5 }, no constructor will be invoked but no warning/error will be emitted either)

A more effective alternative (if you deploy your application as a managed one as opposed to AOTing it) to detect such scenarios is by explicitly asserting that struct instances have been initialized4 (either by a constructor or some other means) prior to accessing its members; since the code is being JITed one can implement this in a way users can control whether the check should be enforced or not and have very little (if any) performance impact when enforcing is disabled, as demonstrated below.

using System.Runtime.CompilerServices;

class Driver
{
    static void Main()
    {

        for(int i = 0; i < 100_000; i++)
        {
            var foo = new Foo(); // No constructor invoked... no warnings :(
            Thread.Sleep(100);
            foo.Use();
        }
    }

}

// The struct can be declared in a different assembly also. 
struct Foo
{
    // it is important for the field to be marked as `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)] // It is important to ask the JIT to inline this method for the optimization to be applied.
    private readonly void Verify()
    {
        if (_shouldValidate)
        {
            if (!_isInitialized)
            {
                throw new Exception("Foo constructor was not invoked; this may be due an array declaration or ...");
            }
        }
    }
}

The code use the field _shouldValidate to control whether correct initialization4 should be enforced or not (in this case more specifically, if the constructor has been executed). Notice that this field is declared as static readonly; this is very important since with this in place the JIT knowns that, for a given struct instance, once initialized, the field value will never change so it is free to handle _shouldValidate as a constant and to not generate code to check it in the if in line #XX; moreover, in case it is evaluated to false the JIT can remove the whole if statement (whence the close to zero overhead mentioned before).

You can see this JIT magic in action by opening a terminal, creating a console application with the code above and running:

DOTNET_JitDisasm=Use dotnet run -c Release

which:

  1. in unix like OSs, sets the environment variable DOTNET_JitDisasm value to Use and runs dotnet run -c Release.
  2. builds the application in release mode (-c Release), which is requirement for the optimization to be applied.
  3. instructs the JIT to dump the JITed assembly code for the method Use() by setting DOTNET_JitDisasm environment variable accordingly.

When executing that command line you should see zeros (0) and some assembly code being printed to the terminal multiple times; after some iterations you should be able to spot some assembly code resembling the one below (make sure to check the one that contains Tier1 as opposed to 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

which is basically calling System.Console.WriteLine(_i) and returning, with no traces of the method Verify() invocation.

You can also play around with this example by running it as:

DOTNET_JitDisasm=Use VALIDATE_FOO=true dotnet run -c Release

in which case it will throw an exception (proving that uninitialized struct instances usage is detected)

or

DOTNET_JitDisasm=Use dotnet run -c Debug

in which case, no matter for how long the application runs, the assembly code generated for Use() will always call Verify() (i.e. optimization was not applied because application was built in debug mode)

With this approach one can be sure that no code is using uninitialized instances by simply running the code with the environment variable set to true and observing for exceptions.

As always, all feedback is welcome.

Have fun!


  1. here you can find some test code I used while exploring this topic.

  2. After realizing that I've changed the post's title :).

  3. This particular case is problematic due to the expectation that the behavior would match the behavior for classes

  4. Note of clarification: From the perspective of the runtime, structs are guaranteed to be initialized (by zeroing out the whole struct) before being used. Initialization in the context of this series of posts means that all struct fields/properties have been assigned meaningful values leaving the instance in a consistent state.