Skip to content
← Tous les articles
· Delta1 Labs Glass.NETDécompilation.NETAnalyse approfondie

Décompiler les types référence nullables : où vit réellement string? dans les métadonnées

string et string? sont le même type pour le runtime — System.String, un seul TypeRef, aucune différence dans aucune signature. Le point d'interrogation n'existe qu'à la compilation, et le compilateur doit le faire passer dans l'assembly quelque part pour que les consommateurs de votre bibliothèque obtiennent les bons avertissements. Il le fait avec deux attributs intégrés, NullableAttribute et NullableContextAttribute, un vocabulaire de trois valeurs en octets, un aplatissement en préordre des arbres de types génériques en tableaux d'octets, et un schéma de compression agressif qui omet ce qui correspond au défaut englobant. Cet article lit ces octets directement depuis l'IL, montre l'encodage exact du blob pour les formes à un octet et à tableau, parcourt comment un décompilateur reconstruit ?, notnull et class? à partir d'eux, et explique pourquoi sauter cette étape change silencieusement le contrat public d'une bibliothèque.

Demandez au runtime quel est le type d”une propriété string? et il vous dira System.String. Demandez-lui une propriété string et il dira la même chose. Il y a un seul TypeRef pour System.String dans l”assembly, les deux propriétés pointent dessus, et les blobs de signature de leurs getters sont octet pour octet identiques. Les types référence nullables sont la fonctionnalité purement à la compilation la plus complète de C# : ils changent les avertissements, pas le code, et ne laissent aucune trace dans le système de types.

Ils laissent bien une trace quelque part, parce qu”il le faut. Si vous publiez une bibliothèque compilée avec #nullable enable, un consommateur qui compile contre elle obtient des avertissements selon lesquels de vos paramètres acceptent null et lesquels de vos retours peuvent en produire. Cette information franchit la frontière de l”assembly, donc elle doit être dans les métadonnées. Le compilateur la stocke dans des attributs personnalisés — deux d”entre eux, intégrés de façon privée dans chaque assembly qui utilise la fonctionnalité, avec un encodage compact en octets et un schéma de compression conçu pour garder les métadonnées petites. Cet article lit ces octets directement, puis montre comment un décompilateur comme Glass.NET les retransforme en le ?, notnull et class? que l”auteur a écrits.

Un type, trois annotations, deux attributs

Voici une petite classe qui exerce chaque cas d”encodage :

#nullable enable
using System.Diagnostics.CodeAnalysis;

public sealed class Catalog<T> where T : notnull
{
    public string Name { get; }                                   // non-nullable
    public string? Description { get; set; }                      // nullable
    public Dictionary<string, List<string?>?> Aliases { get; }    // mixed, nested
    public int? LastIndex { get; set; }                           // value type: Nullable<int>

    public Catalog(string name) { Name = name; Aliases = new(); }

    public bool TryFind(string key, [MaybeNullWhen(false)] out T value) { ... }
}

Le compilateur encode chaque position de type référence avec un octet :

octetsignificationécriture en C#
0oblivious — aucune information d”annotationcode compilé sans #nullable enable
1non annoté — non nullablestring
2annoté — peut être nullstring?

Il porte ces octets dans deux attributs qu”il définit à l”intérieur de votre assembly — non référencés depuis la BCL — en tant que types internal sealed dans System.Runtime.CompilerServices, marqués [Microsoft.CodeAnalysis.Embedded] et [CompilerGenerated] :

.class private auto ansi sealed beforefieldinit System.Runtime.CompilerServices.NullableAttribute
       extends [System.Runtime]System.Attribute
{
  .custom instance void Microsoft.CodeAnalysis.EmbeddedAttribute::.ctor() = ( 01 00 00 00 )
  .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 )
  .field public initonly uint8[] NullableFlags
  .method public hidebysig specialname rtspecialname instance void .ctor(uint8)   cil managed { ... }
  .method public hidebysig specialname rtspecialname instance void .ctor(uint8[]) cil managed { ... }
}

.class private auto ansi sealed beforefieldinit System.Runtime.CompilerServices.NullableContextAttribute
       extends [System.Runtime]System.Attribute
{
  .custom instance void Microsoft.CodeAnalysis.EmbeddedAttribute::.ctor() = ( 01 00 00 00 )
  .field public initonly uint8 Flag
  .method public hidebysig specialname rtspecialname instance void .ctor(uint8) cil managed { ... }
}

NullableAttribute va sur une chose ayant un type — un champ, une propriété, un paramètre, une valeur de retour, un paramètre de type générique, un type de base — et décrit ce type. NullableContextAttribute va sur une portée — un type ou une méthode — et fixe l”octet par défaut de tout ce qui est à l”intérieur et qui n”a pas son propre NullableAttribute. Les deux travaillent ensemble pour garder les métadonnées petites : le compilateur choisit l”octet le plus courant d”une portée comme son contexte et n”émet NullableAttribute que là où un membre diffère.

Lire la forme à un octet

Videz Catalog<T> et les attributs apparaissent sur la classe elle-même et sur Description :

.class public auto ansi sealed beforefieldinit Catalog`1<T>
       extends [System.Runtime]System.Object
{
  .param type T
    .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( 01 00 01 00 00 )
  .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = ( 01 00 01 00 00 )
  .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( 01 00 00 00 00 )

  .property instance string Name()
  {
    .get instance string Catalog`1::get_Name()
  }

  .property instance string Description()
  {
    .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( 01 00 02 00 00 )
    .get instance string Catalog`1::get_Description()
    .set instance void Catalog`1::set_Description(string)
  }
  ...

Le format du blob de l”attribut personnalisé est : un prologue de deux octets 01 00, les arguments fixes du constructeur dans l”ordre, puis un compte de deux octets d”arguments nommés (00 00 ici). Donc ( 01 00 02 00 00 ) est le prologue, l”octet 02, aucun argument nommé — [Nullable(2)], la forme annotée.

Lisez la classe de haut en bas :

  • [NullableContext(1)] sur le type : le défaut de chaque membre de cette classe est 1, non nullable. C”est pourquoi Name ne porte aucun attribut ; son string hérite de 1 du contexte. Le compilateur a choisi 1 car la plupart des références de cette classe sont non nullables ; une classe pleine de ? obtiendrait [NullableContext(2)] et les membres non nullables seraient ceux avec des attributs explicites.
  • [Nullable(0)] sur le type : ceci décrit les types propres à la déclaration de la classe — son type de base et ses interfaces. System.Object ici est oblivious, donc 0. Celui-ci trouble les gens ; il ne dit pas « cette classe est oblivious », il dit « la référence de type de base de cette classe n”a pas d”annotation ».
  • [Nullable(1)] sur le paramètre générique T via .param type T : c”est where T : notnull. Une contrainte notnull n”a aucune représentation au runtime — il n”y a pas de ligne de contrainte pour elle dans la table GenericParamConstraint — donc elle existe uniquement comme cet attribut. Enlevez l”attribut et la contrainte disparaît.
  • [Nullable(2)] sur Description : le seul membre qui diffère du contexte. string?.
  • LastIndex n”a rien, et n”a besoin de rien : int? est Nullable<int>, qui est un type différent dans le blob de signature. Les attributs de nullabilité des références n”ont rien à dire à son sujet.

Les accesseurs de propriété reçoivent leur propre NullableContext quand ils diffèrent de celui du type : get_Description et set_Description sont étiquetés [NullableContext(2)] pour que leur valeur de retour et leur paramètre, qui n”ont pas d”attributs propres, se résolvent à 2. L”attribut de la propriété et les contextes des accesseurs concordent toujours ; un décompilateur peut lire l”un ou l”autre.

Lire la forme à tableau : aplatir un type générique

Aliases est là où l”octet unique cesse de suffire. Dictionary<string, List<string?>?> a quatre positions de type référence avec des annotations différentes, et le compilateur doit les décrire toutes. Il le fait en parcourant l”arbre de types en préordre — le type lui-même, puis chaque argument de type récursivement, de gauche à droite — et en émettant un octet par nœud dans un tableau :

parcours en préordre : nœud, puis arguments de type de gauche à droiteDictionary<,>1string1List<>?2string?2octets aplatis[ 1, 1, 2, 2 ]blob de l''attribut, ctor uint8[]01 00 prologue04 00 00 00 longueur = 401 01 02 02 les flags00 00 aucun arg nommé
  .property instance class [System.Collections]System.Collections.Generic.Dictionary`2<string, class [System.Collections]System.Collections.Generic.List`1<string>> Aliases()
  {
    .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8[]) = ( 01 00 04 00 00 00 01 01 02 02 00 00 )
    .get instance class ... Catalog`1::get_Aliases()
  }

Regardez le type déclaré de la propriété dans l”IL : Dictionary2<string, List1<string>>, aucun point d”interrogation nulle part, car il n”y a nulle part où en mettre dans un blob de signature. Les quatre annotations vivent dans l”attribut : [1, 1, 2, 2] lues dans le même préordre que la figure — Dictionary est 1, string est 1, List<...>? est 2, string? est 2.

Le blob du constructeur uint8[] est le prologue, un compte d”éléments int32 en little-endian (04 00 00 00), les éléments, et le compte d”arguments nommés. Si chaque octet du tableau aplati était identique, le compilateur le réduit au constructeur à un octet — List<string> sous un contexte de 1 n”a besoin d”aucun attribut, et List<string?>? devient [Nullable(2)], pas [Nullable(new byte[] { 2, 2 })]. Un décompilateur doit gérer les deux constructeurs et traiter l”octet unique comme « cette valeur pour chaque position ».

Les types valeur prennent un emplacement mais portent toujours 0. Dictionary<int, string?> s”aplatit en [1, 0, 2] ; Dictionary<int, int?> serait [1, 0, 0, 0] — Dictionary 1, int 0, Nullable<> 0, le int intérieur 0 — qui se réduit, puisque les quatre ne sont pas identiques, à la forme tableau. Les tableaux sont un nœud avec un enfant : string?[] est [1, 2] (tableau non nul de strings nullables) et string[]? est [2, 1]. Les tuples s”aplatissent via ValueTuple<...> de la même façon, c”est pourquoi (string, string?) produit [0, 1, 2].

Paramètres out et les attributs d”analyse

TryFind montre l”autre moitié de l”histoire :

  .method public hidebysig instance bool TryFind(string key, [out] !T& 'value') cil managed
  {
    .param [2]
      .custom instance void [System.Runtime]System.Diagnostics.CodeAnalysis.MaybeNullWhenAttribute::.ctor(bool) = ( 01 00 00 00 00 )
    ...
  }

Rien ici n”est intégré. MaybeNullWhenAttribute est un type public dans la BCL ; il décrit le flux — « quand la méthode renvoie false, ne faites pas confiance à value » — pas la nullabilité déclarée du type. Le décompilateur le montre tel quel, exactement comme il montre tout autre attribut. key ne porte pas de NullableAttribute car la méthode hérite du contexte de 1 de la classe, et !T& n”a rien car T est contraint notnull au niveau de la classe. La division est nette : la nullabilité déclarée (le ? et les contraintes) est dans les attributs intégrés et doit être reconstruite ; les attributs de flux sont des métadonnées ordinaires et doivent simplement ne pas être cachés.

Ce que le décompilateur doit faire

Rendre les octets fidèlement est un petit algorithme avec plusieurs endroits où se tromper :

  1. Trouvez les attributs intégrés par nom, pas par identité. Chaque assembly définit son propre System.Runtime.CompilerServices.NullableAttribute ; il n”y a pas de type partagé contre lequel comparer. Comparez par nom complet, confirmez le marqueur [Embedded], et traitez les définitions elles-mêmes comme de la plomberie du compilateur à cacher de la sortie.
  2. Résolvez le contexte effectif de chaque membre. Le NullableContext propre à une méthode l”emporte sur celui de son type déclarant ; celui d”un type imbriqué l”emporte sur celui de son type externe ; celui d”un accesseur de propriété l”emporte sur celui du type de la propriété. Sans contexte nulle part dans la chaîne, le défaut est 0 — oblivious — ce à quoi ressemble chaque assembly antérieur à C# 8.
  3. Parcourez chaque type de signature en préordre, en consommant des octets. Prenez le NullableAttribute du membre s”il est présent — un octet unique s”applique à chaque position ; un tableau donne un octet par nœud. Sinon, chaque position prend l”octet du contexte. Les types valeur consomment un emplacement et sont ignorés. Tableaux, pointeurs, byrefs et instanciations génériques contribuent chacun leur nœud puis récursent.
  4. Épelez le résultat. Un 2 sur un type référence ou paramètre de type ajoute ? ; un 1 n”ajoute rien ; un 0 signifie que le membre doit être rendu dans une région #nullable disable, ou le fichier entier laissé sans #nullable enable si tout est 0. Un 1 sur un paramètre générique sans autre contrainte de référence devient where T : notnull ; un 2 sur une contrainte class devient class? ; un 2 sur l”usage d”un paramètre non contraint devient T?.
  5. Respectez [NullablePublicOnly]. Quand le module porte ce marqueur, le compilateur a retiré les annotations des membres non publics ; un décompilateur doit rendre ceux-ci comme oblivious plutôt que d”inférer 1 d”un contexte qui, par construction, n”a jamais été censé s”appliquer à eux.
  6. Émettez la directive. Un fichier avec une quelconque annotation 1 ou 2 a besoin de #nullable enable en haut, sinon la sortie recompilée avertit à tous les mauvais endroits.

Glass fait les six. Ouvrez Catalog<T> et la sortie est la classe avec laquelle vous avez commencé : string? Description, Dictionary<string, List<string?>?> Aliases, where T : notnull, [MaybeNullWhen(false)] out T value, et un #nullable enable en haut du fichier. Les deux types d”attribut intégrés n”apparaissent pas dans l”arbre de types — vous pouvez encore les atteindre en passant à la vue IL, où le blob ( 01 00 04 00 00 00 01 01 02 02 00 00 ) est exactement ce que montre la figure ci-dessus — et le [Nullable(0)] sur la déclaration de la classe, qui décrit un object de base oblivious, ne produit correctement rien du tout.

Pourquoi cela compte plus qu”il n”y paraît

Pour la plupart des métadonnées générées par le compilateur, un décompilateur qui les ignore produit une sortie simplement plus laide. La nullabilité est différente, car l”ignorer produit une sortie qui est fausse d”une manière qui compile. Enlevez les attributs et string? Description devient string Description ; where T : notnull s”évapore ; #nullable enable est absent, donc la bibliothèque reconstruite est oblivious partout. Un consommateur qui recompile contre cette bibliothèque reconstruite perd chaque avertissement que l”auteur original y a mis, et dans l”autre sens, un TryFind dont le paramètre out est honnêtement [MaybeNullWhen(false)] à côté d”un type de retour qui affirme maintenant non-null est un contrat qui n”a jamais été publié.

C”est le vrai test d”un décompilateur sur cette fonctionnalité : non pas si la sortie semble plausible, mais si une bibliothèque reconstruite à partir d”elle présente le même contrat de nullabilité à ses consommateurs que l”original. L”information est toute là dans les métadonnées — trois valeurs d”octet, un aplatissement en préordre, un contexte par défaut, et une paire de types d”attribut intégrés — et la relire est ce qui transforme System.String en le string? qui a réellement été écrit.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.